"""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)}