FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe. Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips. init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan). ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config). CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push. VERSION 5.11.1.
182 lines
6.1 KiB
Python
182 lines
6.1 KiB
Python
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
|
|
from app.config import settings
|
|
from app.db import get_conn
|
|
from app.routers.board import _issue_column
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["webhooks"], prefix="/api/webhook")
|
|
|
|
|
|
async def verify_signature(request: Request) -> bool:
|
|
"""Verify Gitea webhook HMAC signature."""
|
|
if not settings.gitea_webhook_secret:
|
|
return True # No secret configured, skip verification
|
|
|
|
sig = request.headers.get("X-Gitea-Signature", "")
|
|
if not sig:
|
|
return False
|
|
|
|
body = await request.body()
|
|
expected = hmac.new(
|
|
settings.gitea_webhook_secret.encode(),
|
|
body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
|
|
return hmac.compare_digest(sig, expected)
|
|
|
|
|
|
@router.post("")
|
|
async def receive_webhook(request: Request):
|
|
"""Receive and process Gitea webhook events."""
|
|
if not await verify_signature(request):
|
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
|
|
|
event_type = request.headers.get("X-Gitea-Event", "")
|
|
body = await request.json()
|
|
|
|
logger.info("Webhook received: %s", event_type)
|
|
|
|
if event_type == "issues":
|
|
await _handle_issue_event(body)
|
|
elif event_type == "pull_request":
|
|
await _handle_pr_event(body)
|
|
elif event_type == "repository":
|
|
await _handle_repo_event(body)
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
async def _handle_issue_event(payload: dict):
|
|
"""Handle issue webhook events."""
|
|
action = payload.get("action", "")
|
|
issue = payload.get("issue", {})
|
|
repo = payload.get("repository", {})
|
|
|
|
owner = repo.get("owner", {}).get("login", "")
|
|
repo_name = repo.get("name", "")
|
|
issue_id = issue.get("number", 0)
|
|
|
|
if not all([owner, repo_name, issue_id]):
|
|
return
|
|
|
|
with get_conn() as conn:
|
|
board = conn.execute(
|
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
|
(owner, repo_name),
|
|
).fetchone()
|
|
|
|
if not board:
|
|
return
|
|
board_id = board["id"]
|
|
|
|
if action in ("opened", "reopened"):
|
|
# Determine column from label mapping (fallback to first column)
|
|
columns = json.loads(board["columns_json"])
|
|
column = _issue_column(issue, columns, board_id)
|
|
conn.execute(
|
|
"""INSERT OR IGNORE INTO cards (board_id, gitea_issue_id, column_name, position)
|
|
VALUES (?, ?, ?, 0)""",
|
|
(board_id, issue_id, column),
|
|
)
|
|
|
|
elif action == "closed":
|
|
# Move to Terminé column
|
|
conn.execute(
|
|
"UPDATE cards SET column_name='Terminé', updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
|
(board_id, issue_id),
|
|
)
|
|
|
|
elif action == "label_updated" or action == "labeled":
|
|
# Check if any new label maps to a column
|
|
labels = issue.get("labels", [])
|
|
for lbl in labels:
|
|
mapping = conn.execute(
|
|
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
|
(board_id, lbl.get("name", "")),
|
|
).fetchone()
|
|
if mapping:
|
|
conn.execute(
|
|
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
|
(mapping["column_name"], board_id, issue_id),
|
|
)
|
|
break
|
|
|
|
elif action == "deleted":
|
|
conn.execute(
|
|
"DELETE FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
|
(board_id, issue_id),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
logger.debug("Issue webhook processed: %s/%s #%d action=%s", owner, repo_name, issue_id, action)
|
|
|
|
|
|
async def _handle_pr_event(payload: dict):
|
|
"""Handle pull request webhook events."""
|
|
# For now, just invalidate cache so board refreshes
|
|
from app.services.gitea_client import gitea
|
|
repo = payload.get("repository", {})
|
|
owner = repo.get("owner", {}).get("login", "")
|
|
repo_name = repo.get("name", "")
|
|
if owner and repo_name:
|
|
gitea._invalidate_issue_cache(owner, repo_name)
|
|
|
|
|
|
async def _handle_repo_event(payload: dict):
|
|
"""Handle repository events (create, delete, etc.)."""
|
|
# Invalidate projects cache
|
|
from app.services.gitea_client import gitea
|
|
gitea._cache.clear()
|
|
|
|
|
|
@router.post("/register/{owner}/{repo}")
|
|
async def register_webhook(owner: str, repo: str, request: Request):
|
|
"""Register a webhook for a specific Gitea repository."""
|
|
from app.services.gitea_client import gitea
|
|
|
|
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
|
if not webhook_url or not settings.gitea_webhook_secret:
|
|
raise HTTPException(status_code=400, detail="Webhook URL or secret not configured")
|
|
|
|
try:
|
|
webhooks = await gitea.list_webhooks(owner, repo)
|
|
|
|
# Check if already registered
|
|
for wh in webhooks:
|
|
if wh.get("url") == webhook_url:
|
|
return {"status": "already_registered", "webhook": wh}
|
|
|
|
# Create webhook
|
|
result = await gitea.create_webhook(owner, repo, webhook_url, settings.gitea_webhook_secret)
|
|
return {"status": "ok", "webhook": result}
|
|
except Exception as e:
|
|
logger.error("Failed to register webhook: %s", e)
|
|
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
|
|
|
|
@router.get("/status/{owner}/{repo}")
|
|
async def webhook_status(owner: str, repo: str):
|
|
"""Check webhook registration status."""
|
|
from app.services.gitea_client import gitea
|
|
|
|
try:
|
|
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
|
webhooks = await gitea.list_webhooks(owner, repo)
|
|
for wh in webhooks:
|
|
if wh.get("url") == webhook_url:
|
|
return {"registered": True, "webhook": wh}
|
|
return {"registered": False}
|
|
except Exception as e:
|
|
return {"registered": False, "error": str(e)}
|