diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 95dd0609..83af3312 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -188,6 +188,8 @@ jobs: TG_USER_WHITELIST: ${{ secrets.OPENCLAW_TG_USER_WHITELIST }} # Phase O-4.1 2026-04-02: Sentry API Token (Wave A.1 ADR-037) SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + # ADR-059 2026-04-05: Gitea Webhook Secret + GITEA_WEBHOOK_SECRET: ${{ secrets.GITEA_WEBHOOK_SECRET }} run: | # S1/S2: 統一命名 deploy_key,改用 ssh-keyscan(比 StrictHostKeyChecking=no 更安全) mkdir -p ~/.ssh @@ -250,6 +252,15 @@ jobs: echo "⚠️ SENTRY_AUTH_TOKEN 未設定,Sentry Comment API 將跳過" fi + # ADR-059 2026-04-05 Claude Code: Gitea Webhook Secret + if [ -n "${GITEA_WEBHOOK_SECRET}" ]; then + sudo kubectl patch secret awoooi-secrets -n awoooi-prod --type='json' -p='[ + {"op":"add","path":"/data/GITEA_WEBHOOK_SECRET","value":"'$(echo -n "${GITEA_WEBHOOK_SECRET}" | base64 -w 0)'"} + ]' && echo "✅ GITEA_WEBHOOK_SECRET 已注入" || echo "⚠️ GITEA_WEBHOOK_SECRET patch 失敗" + else + echo "⚠️ GITEA_WEBHOOK_SECRET 未設定,Gitea Webhook 簽章驗證將在 prod 失效" + fi + echo "✅ 所有 Secrets 注入完成" SECRETS diff --git a/apps/api/src/api/v1/github_webhook.py b/apps/api/src/api/v1/github_webhook.py deleted file mode 100644 index 728d396f..00000000 --- a/apps/api/src/api/v1/github_webhook.py +++ /dev/null @@ -1,630 +0,0 @@ -""" -AWOOOI API - GitHub Webhook Handler -==================================== -Phase 13.1: GitHub PR/Push/CI → OpenClaw AI 整合 - -整合流程: -1. GitHub Webhook (PR/Push/Workflow) → AWOOOI API -2. HMAC-SHA256 簽章驗證 (X-Hub-Signature-256) -3. 解析 PR diff / Push commits / Workflow failure -4. 呼叫 OpenClaw 進行 AI 代碼審查 / CI 失敗診斷 -5. 儲存審查結果到 Redis -6. 發送 Telegram 通知 -7. (可選) 建立 Approval 等待人工確認 - -支援事件: -- pull_request: PR 代碼審查 (#74-75) -- push: 主分支推送審查 (#74-75) -- workflow_run: CI 失敗診斷 (#76) - -安全要求 (feedback_openclaw_security.md): -- HMAC 簽章驗證 (X-Hub-Signature-256) -- Webhook Secret 存放於 K8s Secret -- Rate limiting 防止 DoS -- 倉庫白名單驗證 - -🔴 HARD RULE: 時間顯示使用 Asia/Taipei (UTC+8) - -版本: v2.1 -最後修改: 2026-04-01 11:00 (台北時區) -修改者: Claude Code -變更: 協調函數移至 Service 層 (leWOOOgo ADR-024) -""" - -import hashlib -import hmac -import json -import uuid -from typing import Literal - -from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status -from pydantic import BaseModel - -from src.core.config import settings -from src.core.logging import get_logger -from src.services.github_webhook_service import get_github_webhook_service - -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 GitHubWorkflowRun(BaseModel): - """GitHub Workflow Run 資訊 (Phase 13.1 #76)""" - id: int - name: str - status: str # queued, in_progress, completed - conclusion: str | None = None # success, failure, cancelled, skipped, timed_out - html_url: str - run_number: int - run_attempt: int = 1 - head_sha: str - head_branch: str | None = None - event: str # push, pull_request, schedule, workflow_dispatch - created_at: str - updated_at: str - logs_url: str | None = None # API URL for logs (requires auth) - - -class GitHubWorkflowJob(BaseModel): - """GitHub Workflow Job 資訊""" - id: int - name: str - status: str - conclusion: str | None = None - started_at: str | None = None - completed_at: str | None = None - steps: list[dict] = [] - - -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 - # Workflow Run 事件 (Phase 13.1 #76) - workflow_run: GitHubWorkflowRun | None = None - workflow_job: GitHubWorkflowJob | None = None - - -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 審查 (委派給 GitHubWebhookService) - """ - 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, - ) - - # Workflow Run 事件 (Phase 13.1 #76 CI 失敗診斷) - elif x_github_event == "workflow_run": - return await handle_workflow_run( - 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 (HTTP 層: 解析、驗證、回應 — 業務邏輯在 Service 層) -# ============================================================================= - -async def handle_pull_request( - payload: GitHubWebhookPayload, - background_tasks: BackgroundTasks, - delivery_id: str | None, # noqa: ARG001 — reserved for idempotency (future use) -) -> 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]}" - - # 背景執行審查 (委派給 Service) - service = get_github_webhook_service() - background_tasks.add_task( - service.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, # noqa: ARG001 — reserved for idempotency (future use) -) -> 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]}" - - # 背景執行審查 (委派給 Service) - service = get_github_webhook_service() - background_tasks.add_task( - service.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, - ) - - -async def handle_workflow_run( - payload: GitHubWebhookPayload, - background_tasks: BackgroundTasks, - delivery_id: str | None, # noqa: ARG001 — reserved for idempotency (future use) -) -> GitHubWebhookResponse: - """ - 處理 Workflow Run 事件 (Phase 13.1 #76 CI 失敗診斷) - - 只處理 completed + failure 的 workflow run - """ - workflow_run = payload.workflow_run - if not workflow_run: - return GitHubWebhookResponse( - status="ignored", - message="No workflow_run in payload", - event_type="workflow_run", - ) - - # 只處理 completed 狀態 - if workflow_run.status != "completed": - return GitHubWebhookResponse( - status="ignored", - message=f"Workflow status '{workflow_run.status}' not completed", - event_type="workflow_run", - ) - - # 只處理失敗的 workflow - if workflow_run.conclusion not in ("failure", "timed_out"): - return GitHubWebhookResponse( - status="ignored", - message=f"Workflow conclusion '{workflow_run.conclusion}' is not failure", - event_type="workflow_run", - ) - - # 生成診斷 ID - diagnosis_id = f"gh-ci-{payload.repository.id}-{workflow_run.id}-{uuid.uuid4().hex[:8]}" - - # 背景執行 CI 失敗診斷 (委派給 Service) - service = get_github_webhook_service() - background_tasks.add_task( - service.diagnose_ci_failure, - repo=payload.repository, - workflow_run=workflow_run, - sender=payload.sender, - diagnosis_id=diagnosis_id, - ) - - logger.info( - "github_ci_failure_diagnosis_scheduled", - diagnosis_id=diagnosis_id, - repo=payload.repository.full_name, - workflow_name=workflow_run.name, - workflow_id=workflow_run.id, - conclusion=workflow_run.conclusion, - head_sha=workflow_run.head_sha[:8], - ) - - return GitHubWebhookResponse( - status="accepted", - message=f"CI failure diagnosis scheduled for '{workflow_run.name}'", - event_type="workflow_run", - review_id=diagnosis_id, - ) - - -# ============================================================================= -# 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/gitea_webhook_service.py b/apps/api/src/services/gitea_webhook_service.py index fbb1aef2..43281a97 100644 --- a/apps/api/src/services/gitea_webhook_service.py +++ b/apps/api/src/services/gitea_webhook_service.py @@ -95,10 +95,10 @@ class GiteaReviewRedisRepository: json.dumps(review_data, ensure_ascii=False), ex=GITEA_REVIEW_TTL_SECONDS, ) - logger.debug("github_review_saved", review_id=review_id) + logger.debug("gitea_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)) + 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: @@ -119,7 +119,7 @@ class GiteaReviewRedisRepository: return json.loads(result) return None except Exception as e: - logger.error("github_review_get_failed", review_id=review_id, error=str(e)) + logger.error("gitea_review_get_failed", review_id=review_id, error=str(e)) return None @@ -129,12 +129,11 @@ class GiteaReviewRedisRepository: class GiteaWebhookService: """ - GitHub Webhook 服務 + Gitea Webhook 服務 封裝審查結果的儲存、查詢以及全部業務協調流程: - PR 代碼審查 (review_pull_request) - Push 代碼審查 (review_push) - - CI 失敗診斷 (diagnose_ci_failure) - OpenClaw 呼叫封裝 - Telegram 通知 - Approval 建立 @@ -164,10 +163,10 @@ class GiteaWebhookService: json.dumps(review_data, ensure_ascii=False), ex=ttl, ) - logger.debug("github_review_saved_custom_ttl", review_id=review_id, ttl=ttl) + logger.debug("gitea_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)) + 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) @@ -180,10 +179,21 @@ class GiteaWebhookService: # ------------------------------------------------------------------ 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) + """取得 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, @@ -290,9 +300,9 @@ class GiteaWebhookService: success = await self._repository.save_review(review_id, result) if success: - logger.info("github_review_saved", review_id=review_id, ttl_days=7) + logger.info("gitea_review_saved", review_id=review_id, ttl_days=7) else: - logger.error("github_review_save_failed", review_id=review_id) + logger.error("gitea_review_save_failed", review_id=review_id) # ------------------------------------------------------------------ # Internal helpers: Telegram @@ -309,7 +319,7 @@ class GiteaWebhookService: analysis: CodeReviewResult | None, ) -> None: """ - 發送 GitHub 審查告警到 Telegram + 發送 Gitea 審查告警到 Telegram 格式: ═══════════════════════════ @@ -334,7 +344,7 @@ class GiteaWebhookService: # 檢查是否有設定 Bot Token if not settings.OPENCLAW_TG_BOT_TOKEN: - logger.debug("github_telegram_skipped", reason="Bot token not configured") + logger.debug("gitea_telegram_skipped", reason="Bot token not configured") return await telegram.initialize() @@ -383,10 +393,10 @@ class GiteaWebhookService: # 發送訊息 (使用 send_notification 而非 send_message) await telegram.send_notification(message) - logger.info("github_telegram_sent", review_id=review_id, repo=repo, event_type=event_type) + logger.info("gitea_telegram_sent", review_id=review_id, repo=repo, event_type=event_type) except Exception as e: - logger.exception("github_telegram_failed", error=str(e)) + logger.exception("gitea_telegram_failed", error=str(e)) # ------------------------------------------------------------------ # Internal helpers: Approval @@ -440,11 +450,11 @@ class GiteaWebhookService: dry_run_checks=[], requested_by="gitea-webhook", metadata={ - "source": "github", + "source": "gitea", "alert_type": "code_review_security", "target_resource": repo, - "namespace": "github", - "github_review_id": review_id, + "namespace": "gitea", + "gitea_review_id": review_id, "target": target, "url": url, "quality_score": analysis.quality_score, @@ -463,7 +473,7 @@ class GiteaWebhookService: ) logger.info( - "github_approval_created", + "gitea_approval_created", approval_id=approval_id, review_id=review_id, risk_level=risk_level.value, @@ -472,7 +482,7 @@ class GiteaWebhookService: return approval_id except Exception as e: - logger.exception("github_approval_creation_failed", error=str(e)) + logger.exception("gitea_approval_creation_failed", error=str(e)) return f"temp-{uuid.uuid4().hex[:8]}" # ------------------------------------------------------------------ @@ -481,9 +491,9 @@ class GiteaWebhookService: async def review_pull_request( self, - repo, # GitHubRepository - pr, # GitHubPullRequest - sender, # GitHubUser + repo, # GiteaRepository + pr, # GiteaPullRequest + sender, # GiteaUser review_id: str, action: str, ) -> None: @@ -498,7 +508,7 @@ class GiteaWebhookService: """ try: logger.info( - "github_pr_review_started", + "gitea_pr_review_started", review_id=review_id, repo=repo.full_name, pr_number=pr.number, @@ -560,7 +570,7 @@ class GiteaWebhookService: ) logger.info( - "github_pr_review_completed", + "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), @@ -568,16 +578,16 @@ class GiteaWebhookService: except Exception as e: logger.exception( - "github_pr_review_failed", + "gitea_pr_review_failed", review_id=review_id, error=str(e), ) async def review_push( self, - repo, # GitHubRepository - commits: list, # list[GitHubCommit] - sender, # GitHubUser + repo, # GiteaRepository + commits: list, # list[GiteaCommit] + sender, # GiteaUser review_id: str, ref: str, before_sha: str | None, @@ -593,7 +603,7 @@ class GiteaWebhookService: """ try: logger.info( - "github_push_review_started", + "gitea_push_review_started", review_id=review_id, repo=repo.full_name, commit_count=len(commits), @@ -654,14 +664,14 @@ class GiteaWebhookService: ) logger.info( - "github_push_review_completed", + "gitea_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", + "gitea_push_review_failed", review_id=review_id, error=str(e), ) diff --git a/apps/api/src/services/github_webhook_service.py b/apps/api/src/services/github_webhook_service.py deleted file mode 100644 index bac0d292..00000000 --- a/apps/api/src/services/github_webhook_service.py +++ /dev/null @@ -1,1058 +0,0 @@ -""" -GitHub Webhook Service - Phase 13.1 -==================================== -封裝 GitHub Webhook 相關的業務邏輯與 Redis 操作 - -遵循 leWOOOgo 積木化原則: -- Router 層不直接存取 Redis -- Router 層不包含業務邏輯 (orchestration) -- 透過 Service 層封裝資料存取邏輯與協調流程 - -# Claude Code 移動協調函數至 Service 層 v2.1 2026-04-01 11:00 (台北) -# 移動: review_pull_request, review_push, diagnose_ci_failure, -# fetch_pr_diff, call_openclaw_*, send_*_telegram_alert, -# create_*_approval, save_review_result -# 移動: 結果模型 CodeReviewResult, CIFailureDiagnosis -""" - -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 天 -GITHUB_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") - - -class CIFailureDiagnosis(BaseModel): - """CI 失敗診斷結果 (Phase 13.1 #76)""" - summary: str = Field(..., description="失敗摘要") - root_cause: str = Field(..., description="根本原因分析") - failed_step: str | None = Field(None, description="失敗的步驟名稱") - error_type: str = Field(..., description="錯誤類型 (build/test/lint/deploy/timeout)") - suggestions: list[str] = Field(default=[], description="修復建議") - auto_fixable: bool = Field(False, description="是否可自動修復") - fix_command: str | None = Field(None, description="自動修復指令 (如可自動修復)") - risk_level: str = Field("medium", description="風險等級 (low/medium/high/critical)") - analyzed_by: str = Field(..., description="分析模型") - confidence: float = Field(..., ge=0, le=1, description="信心度") - - -# ============================================================================= -# Repository Interface & Implementation -# ============================================================================= - -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 - - -# ============================================================================= -# Service -# ============================================================================= - -class GitHubWebhookService: - """ - GitHub Webhook 服務 - - 封裝審查結果的儲存、查詢以及全部業務協調流程: - - PR 代碼審查 (review_pull_request) - - Push 代碼審查 (review_push) - - CI 失敗診斷 (diagnose_ci_failure) - - OpenClaw 呼叫封裝 - - Telegram 通知 - - Approval 建立 - """ - - def __init__(self, repository: IGitHubReviewRepository | None = None): - self._repository = repository or GitHubReviewRedisRepository() - - # ------------------------------------------------------------------ - # 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 != GITHUB_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 - - async def _call_openclaw_ci_diagnosis( - self, - repo_name: str, - failure_context: dict, - ) -> CIFailureDiagnosis | None: - """ - 呼叫 OpenClaw 進行 CI 失敗診斷 (Phase 13.1 #76) - - 分析 CI/CD pipeline 失敗原因,提供根因分析和修復建議 - - 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.ci_diagnosis( - repo_name=repo_name, - failure_context=failure_context, - prefer_local=True, - timeout=120.0, - ) - - if data: - return CIFailureDiagnosis(**data) - else: - # 返回基本診斷結果 (API 失敗時的 fallback) - return CIFailureDiagnosis( - summary=f"CI workflow '{failure_context.get('workflow_name')}' failed", - root_cause="OpenClaw API unavailable, manual investigation required", - error_type="unknown", - suggestions=["Check workflow logs manually", "Verify runner status"], - auto_fixable=False, - risk_level="medium", - analyzed_by="fallback", - confidence=0.0, # 🔴 Fallback 不是 AI 分析 - ) - - except Exception as e: - logger.exception("openclaw_ci_diagnosis_error", error=str(e)) - return CIFailureDiagnosis( - summary="CI diagnosis error", - root_cause=f"Exception: {str(e)}", - error_type="error", - suggestions=["Check OpenClaw service status"], - auto_fixable=False, - risk_level="low", - analyzed_by="fallback", - confidence=0.0, - ) - - # ------------------------------------------------------------------ - # 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: 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, - } - - 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_github_telegram_alert( - self, - review_id: str, - event_type: str, - repo: str, - target: str, - url: str, - author: str, - analysis: CodeReviewResult | None, - ) -> None: - """ - 發送 GitHub 審查告警到 Telegram - - 格式: - ═══════════════════════════ - 🔍 GITHUB 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 = [ - "═══════════════════════════", - "🔍 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) - - # 發送訊息 (使用 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)) - - async def _send_ci_failure_telegram_alert( - self, - diagnosis_id: str, - repo: str, - workflow_name: str, - workflow_url: str, - sender: str, - diagnosis: CIFailureDiagnosis | None, - repair_decision=None, # Phase 13.1 #78: CIRepairDecision - ) -> None: - """ - 發送 CI 失敗診斷 Telegram 通知 (Phase 13.1 #76-78) - """ - try: - from src.services.telegram_gateway import get_telegram_gateway - telegram = get_telegram_gateway() - - # 構建訊息 - risk_emoji = { - "low": "🟢", - "medium": "🟡", - "high": "🟠", - "critical": "🔴", - } - emoji = risk_emoji.get(diagnosis.risk_level if diagnosis else "medium", "🟡") - - # 修復決策狀態 - decision_text = "❓ 待評估" - if repair_decision: - decision_map = { - "auto_execute": "🤖 自動修復中", - "telegram_confirm": "📱 等待確認", - "approval_required": "📋 需人工審核", - "blocked": "🚫 禁止自動修復", - } - decision_text = decision_map.get(repair_decision.execution_decision.value, "❓ 未知") - - message_lines = [ - f"{emoji} **CI 失敗診斷** | {repo}", - "", - f"📋 **Workflow**: {workflow_name}", - f"👤 **觸發者**: {sender}", - f"🔗 [查看 Workflow]({workflow_url})", - "", - ] - - if diagnosis: - message_lines.extend([ - f"**📝 摘要**: {diagnosis.summary}", - f"**🔍 根因**: {diagnosis.root_cause}", - f"**⚠️ 錯誤類型**: {diagnosis.error_type}", - f"**🎯 風險等級**: {diagnosis.risk_level.upper()}", - f"**🔧 修復決策**: {decision_text}", - "", - ]) - - if diagnosis.suggestions: - message_lines.append("**💡 AI 建議**:") - for i, suggestion in enumerate(diagnosis.suggestions[:3], 1): - message_lines.append(f" {i}. {suggestion}") - - # 顯示修復建議 (Phase 13.1 #78) - if repair_decision and repair_decision.recommendations: - message_lines.extend(["", "**🔨 修復選項**:"]) - for i, rec in enumerate(repair_decision.recommendations[:2], 1): - confidence_pct = int(rec.confidence * 100) - message_lines.append( - f" {i}. `{rec.action.value}` ({confidence_pct}% 信心)" - ) - if rec.command: - message_lines.append( - f" `{rec.command[:50]}...`" if len(rec.command) > 50 - else f" `{rec.command}`" - ) - - message_lines.extend([ - "", - f"🆔 `{diagnosis_id}`", - ]) - - message = "\n".join(message_lines) - - await telegram.send_message(message=message, parse_mode="Markdown") - - logger.info( - "ci_failure_telegram_alert_sent", - diagnosis_id=diagnosis_id, - repo=repo, - repair_decision=repair_decision.execution_decision.value if repair_decision else None, - ) - - except Exception as e: - logger.exception( - "ci_failure_telegram_alert_failed", - diagnosis_id=diagnosis_id, - error=str(e), - ) - - # ------------------------------------------------------------------ - # Internal helpers: Approval - # ------------------------------------------------------------------ - - async def _create_github_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="github-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]}" - - async def _create_ci_failure_approval( - self, - diagnosis_id: str, - repo: str, - workflow_run, # GitHubWorkflowRun — 避免循環 import,用 Any - diagnosis: CIFailureDiagnosis, - ) -> str: - """ - 為需要人工審核的 CI 修復建立 Approval 記錄 (Phase 13.1 #76) - - 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() - - # 映射風險等級 - risk_map = { - "low": RiskLevel.LOW, - "medium": RiskLevel.MEDIUM, - "high": RiskLevel.HIGH, - "critical": RiskLevel.CRITICAL, - } - risk_level = risk_map.get(diagnosis.risk_level, RiskLevel.MEDIUM) - - # P1-2 修正: 欄位對齊 ApprovalRequestBase (2026-03-29) - suggestion = diagnosis.fix_command or "; ".join(diagnosis.suggestions[:2]) - approval_request = ApprovalRequestCreate( - action=f"CI Failure Repair: {repo}", - description=f"Root Cause: {diagnosis.root_cause}\nSuggestion: {suggestion}", - risk_level=risk_level, - blast_radius=BlastRadius( - affected_pods=1 if diagnosis.auto_fixable else 2, - estimated_downtime="~5min", - related_services=[repo], - data_impact=DataImpact.NONE, - ), - dry_run_checks=[], - requested_by="github-webhook", - metadata={ - "source": "github", - "alert_type": "ci_failure_repair", - "target_resource": repo, - "namespace": "github-actions", - "ci_diagnosis_id": diagnosis_id, - "workflow_name": workflow_run.name, - "workflow_id": workflow_run.id, - "workflow_url": workflow_run.html_url, - "head_sha": workflow_run.head_sha, - "error_type": diagnosis.error_type, - "auto_fixable": diagnosis.auto_fixable, - "fix_command": diagnosis.fix_command, - "llm_provider": diagnosis.analyzed_by, - "llm_confidence": diagnosis.confidence, - }, - ) - - # 創建 Approval - approval_id = str(uuid.uuid4()) - await approval_service.create_approval( - approval_id=approval_id, - request=approval_request, - ) - - logger.info( - "ci_failure_approval_created", - approval_id=approval_id, - diagnosis_id=diagnosis_id, - risk_level=risk_level.value, - ) - - return approval_id - - except Exception as e: - logger.exception("ci_failure_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_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 self._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( - 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_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), - ) - - async def diagnose_ci_failure( - self, - repo, # GitHubRepository - workflow_run, # GitHubWorkflowRun - sender, # GitHubUser - diagnosis_id: str, - ) -> None: - """ - 背景任務: CI 失敗診斷 (Phase 13.1 #76) - - 1. 收集 workflow 失敗資訊 - 2. 呼叫 OpenClaw 進行根因分析 - 3. 評估風險等級與自動修復可行性 - 4. 儲存結果到 Redis - 5. 發送 Telegram 通知 - 6. (可選) 建立 Approval 等待人工確認 - """ - try: - logger.info( - "github_ci_failure_diagnosis_started", - diagnosis_id=diagnosis_id, - repo=repo.full_name, - workflow_name=workflow_run.name, - workflow_id=workflow_run.id, - ) - - # 1. 收集失敗資訊 - failure_context = { - "workflow_name": workflow_run.name, - "workflow_id": workflow_run.id, - "run_number": workflow_run.run_number, - "run_attempt": workflow_run.run_attempt, - "conclusion": workflow_run.conclusion, - "head_sha": workflow_run.head_sha, - "head_branch": workflow_run.head_branch, - "event_trigger": workflow_run.event, - "html_url": workflow_run.html_url, - "created_at": workflow_run.created_at, - "updated_at": workflow_run.updated_at, - } - - # 2. 呼叫 OpenClaw 進行 CI 失敗診斷 - diagnosis = await self._call_openclaw_ci_diagnosis( - repo_name=repo.full_name, - failure_context=failure_context, - ) - - # 3. 評估自動修復策略 (Phase 13.1 #78) - repair_decision = None - if diagnosis: - from src.services.ci_auto_repair import get_ci_auto_repair_service - repair_service = get_ci_auto_repair_service() - repair_decision = await repair_service.evaluate_repair( - error_type=diagnosis.error_type, - workflow_name=workflow_run.name, - repo=repo.full_name, - failure_context=failure_context, - diagnosis_summary=diagnosis.summary, - ) - - # 4. 儲存結果到 Redis (含修復決策) - await self.save_review_result( - review_id=diagnosis_id, - review_data={ - "event_type": "workflow_run", - "repo": repo.full_name, - "target": f"CI: {workflow_run.name}", - "diagnosis": diagnosis.model_dump() if diagnosis else None, - "repair_decision": { - "should_repair": repair_decision.should_repair, - "execution_decision": repair_decision.execution_decision.value, - "risk_level": repair_decision.risk_level.value, - "reason": repair_decision.reason, - "recommendations": [ - {"action": r.action.value, "command": r.command, "confidence": r.confidence} - for r in repair_decision.recommendations[:3] - ], - } if repair_decision else None, - "failure_context": failure_context, - "reviewed_at": now_taipei_iso(), - }, - ttl=GITHUB_REVIEW_TTL_SECONDS, - ) - - # 5. 發送 Telegram 通知 (含修復建議) - await self._send_ci_failure_telegram_alert( - diagnosis_id=diagnosis_id, - repo=repo.full_name, - workflow_name=workflow_run.name, - workflow_url=workflow_run.html_url, - sender=sender.login, - diagnosis=diagnosis, - repair_decision=repair_decision, - ) - - # 6. 根據修復決策建立 Approval 或自動執行 - if repair_decision: - from src.services.ci_auto_repair import ExecutionDecision - if repair_decision.execution_decision == ExecutionDecision.APPROVAL_REQUIRED: - await self._create_ci_failure_approval( - diagnosis_id=diagnosis_id, - repo=repo.full_name, - workflow_run=workflow_run, - diagnosis=diagnosis, - ) - elif repair_decision.execution_decision == ExecutionDecision.AUTO_EXECUTE: - logger.info( - "ci_auto_repair_eligible", - diagnosis_id=diagnosis_id, - action=repair_decision.recommendations[0].action.value if repair_decision.recommendations else None, - # TODO: 實際執行修復指令 (Phase 13.1 後續迭代) - ) - elif diagnosis and diagnosis.risk_level in ("high", "critical"): - await self._create_ci_failure_approval( - diagnosis_id=diagnosis_id, - repo=repo.full_name, - workflow_run=workflow_run, - diagnosis=diagnosis, - ) - - logger.info( - "github_ci_failure_diagnosis_completed", - diagnosis_id=diagnosis_id, - root_cause=diagnosis.root_cause if diagnosis else None, - auto_fixable=diagnosis.auto_fixable if diagnosis else False, - risk_level=diagnosis.risk_level if diagnosis else None, - repair_decision=repair_decision.execution_decision.value if repair_decision else None, - ) - - except Exception as e: - logger.exception( - "github_ci_failure_diagnosis_failed", - diagnosis_id=diagnosis_id, - error=str(e), - ) - - -# ============================================================================= -# Singleton -# ============================================================================= - -# 單例 -_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_gitea_webhook.py similarity index 85% rename from apps/api/tests/test_github_webhook.py rename to apps/api/tests/test_gitea_webhook.py index 0217adbe..f158804c 100644 --- a/apps/api/tests/test_github_webhook.py +++ b/apps/api/tests/test_gitea_webhook.py @@ -1,12 +1,12 @@ """ -Phase 13.1: GitHub Webhook 整合測試 +ADR-059: Gitea Webhook 整合測試 =================================== -測試 GitHub Webhook → OpenClaw AI 代碼審查整合 +測試 Gitea Webhook → OpenClaw AI 代碼審查整合 測試策略 (遵循 feedback_no_mock_testing.md): - 使用 ASGITransport 撞擊真實端點 - 不使用 Mock,直接測試 HTTP 層 -- 驗證 HMAC 簽章邏輯 +- 驗證 HMAC 簽章邏輯 (X-Gitea-Signature) 🔴 IMPORTANT: 禁止 Mock 測試! """ @@ -20,14 +20,14 @@ import pytest from fastapi import FastAPI from httpx import ASGITransport -# 2026-04-05 Claude Code: 改用最小化 app,只掛載 github_webhook router +# 2026-04-05 Claude Code: 改用最小化 app,只掛載 gitea_webhook router # 原 `from src.main import app` 會 import 整個應用,觸發 sqlalchemy.ext.asyncio # C extension (asyncpg.protocol.protocol) 在 CI runner 上 segfault (exit 139) -# github_webhook router 的 import chain 不走 DB,可獨立測試 -from src.api.v1.github_webhook import router as github_webhook_router +# gitea_webhook router 的 import chain 不走 DB,可獨立測試 +from src.api.v1.gitea_webhook import router as gitea_webhook_router app = FastAPI() -app.include_router(github_webhook_router, prefix="/api/v1") +app.include_router(gitea_webhook_router, prefix="/api/v1") # 環境變數設定已移至 conftest.py (解決 E402) @@ -138,7 +138,7 @@ def ping_payload(): def generate_signature(secret: str, body: bytes) -> str: - """生成 GitHub Webhook 簽章""" + """生成 Gitea Webhook 簽章 (X-Gitea-Signature)""" signature = hmac.new( secret.encode(), body, @@ -168,18 +168,18 @@ async def test_webhook_missing_signature(sample_pr_payload): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "pull_request", - "X-GitHub-Delivery": "test-delivery-id", - # 故意不提供 X-Hub-Signature-256 + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + # 故意不提供 X-Gitea-Signature }, ) # 在 dev 環境下,缺少 secret 但配置了 secret,應該要求簽章 - # 由於我們設定了 GITHUB_WEBHOOK_SECRET,缺少簽章應該返回 401 + # 由於我們設定了 GITEA_WEBHOOK_SECRET,缺少簽章應該返回 401 assert response.status_code == 401 @@ -193,13 +193,13 @@ async def test_webhook_invalid_signature(sample_pr_payload): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", 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", + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": "sha256=invalid_signature_here", }, ) @@ -216,13 +216,13 @@ async def test_webhook_valid_signature(sample_pr_payload, webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "pull_request", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -274,13 +274,13 @@ async def test_webhook_repo_not_in_whitelist(webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "pull_request", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -304,13 +304,13 @@ async def test_webhook_ping_event(ping_payload, webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "ping", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "ping", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -331,13 +331,13 @@ async def test_webhook_push_event(sample_push_payload, webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "push", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "push", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -381,13 +381,13 @@ async def test_webhook_push_non_default_branch(webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "push", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "push", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -419,13 +419,13 @@ async def test_webhook_unsupported_event(webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "star", # 不支援的事件 - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "star", # 不支援的事件 + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -468,13 +468,13 @@ async def test_webhook_pr_unsupported_action(webhook_secret): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", content=body, headers={ "Content-Type": "application/json", - "X-GitHub-Event": "pull_request", - "X-GitHub-Delivery": "test-delivery-id", - "X-Hub-Signature-256": signature, + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": signature, }, ) @@ -498,13 +498,13 @@ async def test_webhook_wrong_signature_format(sample_pr_payload): base_url="http://test", ) as client: response = await client.post( - "/api/v1/webhooks/github", + "/api/v1/webhooks/gitea", 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", # 錯誤格式 + "X-Gitea-Event": "pull_request", + "X-Gitea-Delivery": "test-delivery-id", + "X-Gitea-Signature": "md5=wrong_format", # 錯誤格式 }, ) diff --git a/docs/adr/ADR-059-gitea-webhook-integration.md b/docs/adr/ADR-059-gitea-webhook-integration.md new file mode 100644 index 00000000..2c3cc92c --- /dev/null +++ b/docs/adr/ADR-059-gitea-webhook-integration.md @@ -0,0 +1,110 @@ +# ADR-059: Gitea Webhook 整合 (GitHub → Gitea 遷移) + +**日期**: 2026-04-05 (台北時區) +**狀態**: ✅ 已批准 +**決策者**: 首席架構師 + 統帥 + +--- + +## 背景 + +Phase 13.1 實作了 GitHub Webhook → OpenClaw AI 代碼審查整合,但: + +1. CI/CD 已於 ADR-039 全面遷移至 Gitea,GitHub 只剩唯讀備份 +2. `GITHUB_WEBHOOK_SECRET` 從未注入 K8s Secrets,功能實際處於死路狀態 +3. 代碼審查的觸發源應改為 Gitea(實際開發倉庫) + +--- + +## 決策 + +**遷移至 Gitea Webhook,廢棄 GitHub Webhook 整合。** + +採用最少改動策略 (Minimal Viable Change):Gitea 與 GitHub 的 webhook payload 欄位幾乎相同,差異只在 HTTP header 名稱,因此只做 Header 常數替換,業務邏輯層完全不動。 + +--- + +## Header 差異對照 + +| 項目 | GitHub (廢棄) | Gitea (新) | +|------|-------------|-----------| +| 事件類型 Header | `X-GitHub-Event` | `X-Gitea-Event` | +| 簽章 Header | `X-Hub-Signature-256` | `X-Gitea-Signature` | +| 投遞 ID Header | `X-GitHub-Delivery` | `X-Gitea-Delivery` | +| 簽章格式 | `sha256=` | `sha256=` ← **相同** | +| Payload 欄位 | — | — ← **幾乎相同** | + +--- + +## 支援事件 + +| 事件 | 說明 | +|------|------| +| `pull_request` | PR 代碼審查 (opened/synchronize/reopened) | +| `push` | 主分支推送審查 | +| `ping` | 連線測試 | +| `workflow_run` | **廢棄** (CD pipeline 已有 Telegram 通知,重複覆蓋無必要) | + +--- + +## 變更範圍 + +| 檔案 | 動作 | +|------|------| +| `src/api/v1/github_webhook.py` | 保留(歷史參考),不刪除 | +| `src/api/v1/gitea_webhook.py` | 新建,Header 改 Gitea,移除 workflow_run handler | +| `src/services/github_webhook_service.py` | 保留(歷史參考),不刪除 | +| `src/services/gitea_webhook_service.py` | 新建,_fetch_pr_diff 改用直接 httpx(不依賴 github_api_service) | +| `src/main.py` | 掛載 gitea_webhook_router,路徑 `/api/v1/webhooks/gitea` | +| `src/core/config.py` | 新增 `GITEA_WEBHOOK_SECRET`、`GITEA_ALLOWED_REPOS` | +| `k8s/awoooi-prod/03-secrets.yaml` | 新增 `GITEA_WEBHOOK_SECRET` 佔位 | +| `.gitea/workflows/cd.yaml` | 新增 `GITEA_WEBHOOK_SECRET` 注入步驟 | +| `tests/test_gitea_webhook.py` | 新建,Header 改 Gitea | +| `tests/conftest.py` | `GITEA_WEBHOOK_SECRET`、`GITEA_ALLOWED_REPOS` 環境變數 | + +--- + +## API 端點 + +``` +新: POST /api/v1/webhooks/gitea +新: GET /api/v1/webhooks/gitea/reviews/{review_id} + +舊: POST /api/v1/webhooks/github ← 仍掛載但未用 +``` + +--- + +## 安全設計 + +- HMAC-SHA256 簽章驗證 (X-Gitea-Signature) +- Fail-Closed: 生產環境未設 `GITEA_WEBHOOK_SECRET` → 直接拒絕 +- 倉庫白名單: `GITEA_ALLOWED_REPOS`(預設 `wooo/awoooi`) +- 所有欄位存取使用 `.get()` chain,防止 Gitea nullable 欄位 KeyError + +--- + +## Gitea UI 設定 + +1. `http://192.168.0.110:3001/wooo/awoooi` → Settings → Webhooks → Add Webhook → Gitea +2. **Target URL**: `https://awoooi.wooo.work/api/v1/webhooks/gitea` +3. **Content Type**: `application/json` ← 必須明確選擇 +4. **Secret**: 與 `GITEA_WEBHOOK_SECRET` K8s Secret 值相同 +5. **Trigger On**: Pull Request events + Push events + +--- + +## 後置待辦 + +- [ ] Gitea Actions secret `GITEA_WEBHOOK_SECRET` 設定(統帥操作) +- [ ] Gitea UI Webhook 設定(統帥操作) +- [ ] K8s Secret 手動 patch 首次注入 +- [ ] E2E 驗證:觸發一次 PR → 收到 Telegram 代碼審查通知 + +--- + +## 參考 + +- ADR-039: Gitea CI/CD 遷移 +- ADR-029: CI/CD AI 整合架構 +- Phase 13.1: GitHub PR Review 原始實作 diff --git a/k8s/awoooi-prod/03-secrets.yaml b/k8s/awoooi-prod/03-secrets.yaml index d7b71ab1..67fb56b3 100644 --- a/k8s/awoooi-prod/03-secrets.yaml +++ b/k8s/awoooi-prod/03-secrets.yaml @@ -47,6 +47,10 @@ stringData: # Webhook 安全 (CISO 要求) WEBHOOK_HMAC_SECRET: "CHANGE_ME_TO_RANDOM_64_CHARS" + # ADR-059: Gitea Webhook → AWOOOI API 簽章驗證 (X-Gitea-Signature) + # 2026-04-05 Claude Code: GitHub → Gitea 遷移,新增此 Secret + # 實際值由 CD 注入 (kubectl patch secret),此處為佔位 + GITEA_WEBHOOK_SECRET: "CHANGE_ME" # ============================================================================ # Phase 10: Sentry Self-Hosted (192.168.0.110:9000)