feat(webhook): ADR-059 GitHub → Gitea Webhook 遷移完成

- gitea_webhook.py: Header 全部改 X-Gitea-*,移除 workflow_run handler
- gitea_webhook_service.py: _fetch_pr_diff 改直接 httpx,不依賴 github_api_service
- 清除兩個檔案的所有殘留 github_ log key,review_id prefix 改 gitea-
- test_gitea_webhook.py: 10/10 通過,docstring 修正
- 03-secrets.yaml: 新增 GITEA_WEBHOOK_SECRET 佔位
- cd.yaml: 新增 GITEA_WEBHOOK_SECRET 注入步驟
- ADR-059: 建立架構決策文件

待統帥操作: Gitea Actions secret + Gitea UI Webhook 設定

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-05 14:44:32 +08:00
parent b2c0148f2b
commit 23364423fa
7 changed files with 218 additions and 1771 deletions

View File

@@ -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

View File

@@ -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=<hex_digest>
- 使用 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

View File

@@ -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),
)

File diff suppressed because it is too large Load Diff

View File

@@ -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", # 錯誤格式
},
)

View File

@@ -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 全面遷移至 GiteaGitHub 只剩唯讀備份
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=<hex>` | `sha256=<hex>`**相同** |
| 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 原始實作

View File

@@ -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)