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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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", # 錯誤格式
|
||||
},
|
||||
)
|
||||
|
||||
110
docs/adr/ADR-059-gitea-webhook-integration.md
Normal file
110
docs/adr/ADR-059-gitea-webhook-integration.md
Normal 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 全面遷移至 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=<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 原始實作
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user