Files
flowdeck/app/routers/webhooks.py
T
bruno 7b2fa01b0a
CI / test (push) Failing after 8s
CI / lint (push) Failing after 4s
fix: positionnement des cartes selon les labels et mise à jour après édition
- create_issue: utilise _issue_column() au lieu de hardcoder 'Backlog'
- update_issue_api: recalcule la colonne après changement de labels
- update_issue_api: utilise l'endpoint PUT /labels au lieu de PATCH (Gitea ignore labels dans PATCH)
- webhooks: utilise _issue_column() pour 'opened'/'reopened'
- saveIssueEdit: rafraîchit le board (HTMX) après édition au lieu de rouvrir la modale
- update_issue_labels: corrigé pour utiliser PUT /issues/{id}/labels (liste d'entiers)
2026-07-08 11:27:56 -04:00

182 lines
6.1 KiB
Python

"""FlowDeck — Webhook receiver for real-time Gitea sync."""
from __future__ import annotations
import json
import hmac
import hashlib
import logging
from fastapi import APIRouter, Request, HTTPException
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))
@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)}