v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
This commit is contained in:
+37
-24
@@ -1,32 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [0.2.0] — 2026-07-08 — "FlowDeck"
|
## [0.3.0] — 2026-07-08 — "Auth + Card Detail + Webhooks"
|
||||||
|
|
||||||
### Added
|
### Added — Auth & Multi-user (v0.3.0)
|
||||||
- Renamed from Kanbante to FlowDeck
|
- OAuth2 Gitea (login via Gitea) — /auth/login, /auth/callback, /auth/logout
|
||||||
- Dashboard listant tous les projets Gitea (user + orgs)
|
- Token par utilisateur stocké en BDD (user_tokens table)
|
||||||
- Board Kanban 5 colonnes : Backlog, À faire, En cours, Révision, Terminé
|
- Session signed cookies (itsdangerous) — 7 jours
|
||||||
- Issues Gitea → cartes avec filtres (milestone, label, assignee)
|
- CSRF protection middleware sur tous les POST/PUT/PATCH/DELETE
|
||||||
- Drag & drop (SortableJS) avec persistance SQLite
|
- Rate limiting in-memory (60 req/min par IP, configurable)
|
||||||
- Sync bidirectionnelle : colonne → label Gitea, Terminé → close issue
|
- Fallback mode sans OAuth: connexion automatique en tant qu'admin
|
||||||
- Colonnes personnalisables + mapping colonne→label
|
|
||||||
- WIP limits configurables
|
### Added — Issue Creation & Editing (v0.5.0 partial)
|
||||||
- Notes Markdown par projet
|
- Création d'issues depuis FlowDeck (POST /api/issues/{owner}/{repo})
|
||||||
- API REST v1 : /api/health, /api/stats, /api/projects, /api/move, /api/col-mapping
|
- Formulaire inline avec labels, milestone, assignee
|
||||||
- Thème clair/sombre avec persistance localStorage
|
- Édition de titre inline (PATCH /api/issues/{owner}/{repo}/{issue_id})
|
||||||
- Cache Gitea in-memory avec TTL
|
- Card metadata: priority + due date (POST /api/card/{owner}/{repo}/{issue_id})
|
||||||
- Docker + docker-compose
|
|
||||||
- Health check enrichi (DB + Gitea)
|
### Added — Webhooks (v0.4.0)
|
||||||
- Filtres intra-board (milestone, label, assignee)
|
- Endpoint webhook receiver: POST /api/webhook
|
||||||
- Pull requests affichées séparément
|
- Signature HMAC-SHA256 verification
|
||||||
- Priorités (Low/Medium/High/Urgent) + dates d'échéance
|
- Gère: issue opened/closed/labeled/deleted, PR, repository
|
||||||
- Architecture documentée (ARCHITECTURE.md)
|
- Auto-registration: POST /api/webhook/register/{owner}/{repo}
|
||||||
- Roadmap (ROADMAP.md)
|
- Webhook status: GET /api/webhook/status/{owner}/{repo}
|
||||||
|
- GiteaClient: list_webhooks, create_webhook, delete_webhook
|
||||||
|
|
||||||
|
### Added — Card Detail + Checklists (v0.5.0)
|
||||||
|
- Modal détail carte (double-clic): description, labels, assignee, milestone, commentaires
|
||||||
|
- Checklists avec items checkable + ajout/suppression inline
|
||||||
|
- Priorités (Low/Medium/High/Urgent) + dates d'échéance éditables
|
||||||
|
- Commentaires Gitea affichés dans le modal
|
||||||
|
- GET /api/issues/{owner}/{repo}/{issue_id} — détail complet
|
||||||
|
- GET /api/collaborators/{owner}/{repo} — pour assignee picker
|
||||||
|
- Bouton "🔔 Webhook" pour enregistrer le webhook Gitea
|
||||||
|
|
||||||
### Stack
|
### Stack
|
||||||
- FastAPI + Uvicorn + httpx + Jinja2 + HTMX + Alpine.js + SortableJS
|
- + itsdangerous (signed sessions)
|
||||||
- SQLite WAL mode
|
- + slowapi (rate limiting, optional)
|
||||||
- Python 3.12-slim Docker
|
- + starlette SessionMiddleware pour OAuth2 state
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-07-08 — "FlowDeck"
|
||||||
|
- Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||||
|
|
||||||
## [0.1.0] — Kanbante (non publié)
|
## [0.1.0] — Kanbante (non publié)
|
||||||
- MVP initial
|
- MVP initial
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
|
||||||
|
from app.auth.oauth import GiteaOAuth
|
||||||
|
from app.auth.session import SessionManager, get_current_user
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""FlowDeck — Gitea OAuth2 client."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaOAuth:
|
||||||
|
"""Gitea OAuth2 client for user authentication."""
|
||||||
|
|
||||||
|
AUTHORIZE_URL = f"{settings.gitea_url}/login/oauth/authorize"
|
||||||
|
TOKEN_URL = f"{settings.gitea_url}/login/oauth/access_token"
|
||||||
|
USER_URL = f"{settings.gitea_url}/api/v1/user"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = settings.gitea_oauth_client_id
|
||||||
|
self.client_secret = settings.gitea_oauth_client_secret
|
||||||
|
self.redirect_uri = settings.oauth_redirect_uri
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self.client_id and self.client_secret)
|
||||||
|
|
||||||
|
def get_authorize_url(self, state: str) -> str:
|
||||||
|
params = {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
return f"{self.AUTHORIZE_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
async def exchange_code(self, code: str) -> dict | None:
|
||||||
|
"""Exchange authorization code for access token."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
self.TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"redirect_uri": self.redirect_uri,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("OAuth token exchange failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_user(self, access_token: str) -> dict | None:
|
||||||
|
"""Get user info from Gitea API."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
self.USER_URL,
|
||||||
|
headers={"Authorization": f"token {access_token}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("OAuth user fetch failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
gitea_oauth = GiteaOAuth()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""FlowDeck — Session management with signed cookies."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionManager:
|
||||||
|
"""Manages user sessions via signed cookies."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_session(user_data: dict) -> str:
|
||||||
|
"""Create a signed session cookie value."""
|
||||||
|
payload = {
|
||||||
|
"user": user_data,
|
||||||
|
"created_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
return _serializer.dumps(payload)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode_session(cookie: str) -> dict | None:
|
||||||
|
"""Decode and validate a session cookie. Returns user data or None."""
|
||||||
|
try:
|
||||||
|
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
|
||||||
|
return payload.get("user")
|
||||||
|
except (BadSignature, SignatureExpired):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def store_token(user_id: int, gitea_token: str) -> None:
|
||||||
|
"""Store a user's Gitea OAuth token in SQLite."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO user_tokens (gitea_user_id, gitea_token, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(gitea_user_id)
|
||||||
|
DO UPDATE SET gitea_token=excluded.gitea_token, updated_at=CURRENT_TIMESTAMP""",
|
||||||
|
(user_id, gitea_token),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_token(user_id: int) -> str | None:
|
||||||
|
"""Get a user's stored Gitea token."""
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT gitea_token FROM user_tokens WHERE gitea_user_id=?",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row["gitea_token"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# FastAPI dependency
|
||||||
|
async def get_current_user(request) -> dict | None:
|
||||||
|
"""FastAPI dependency: extract current user from session cookie."""
|
||||||
|
from fastapi import Request
|
||||||
|
session = request.cookies.get("flowdeck_session")
|
||||||
|
if session:
|
||||||
|
return SessionManager.decode_session(session)
|
||||||
|
return None
|
||||||
@@ -17,6 +17,12 @@ class Settings(BaseSettings):
|
|||||||
gitea_oauth_client_secret: str = ""
|
gitea_oauth_client_secret: str = ""
|
||||||
gitea_webhook_secret: str = ""
|
gitea_webhook_secret: str = ""
|
||||||
|
|
||||||
|
# OAuth2
|
||||||
|
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
|
||||||
|
|
||||||
|
# Webhook
|
||||||
|
webhook_base_url: str = "http://localhost:8080"
|
||||||
|
|
||||||
# App
|
# App
|
||||||
app_secret_key: str = "change-me-to-random"
|
app_secret_key: str = "change-me-to-random"
|
||||||
app_host: str = "0.0.0.0"
|
app_host: str = "0.0.0.0"
|
||||||
@@ -24,6 +30,10 @@ class Settings(BaseSettings):
|
|||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
default_lang: str = "fr"
|
default_lang: str = "fr"
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
rate_limit_enabled: bool = True
|
||||||
|
rate_limit_requests: int = 60 # per minute
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
database_url: str = "sqlite:////data/flowdeck.db"
|
database_url: str = "sqlite:////data/flowdeck.db"
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ def init_db():
|
|||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
full_name TEXT NOT NULL DEFAULT '',
|
||||||
|
email TEXT NOT NULL DEFAULT '',
|
||||||
|
avatar_url TEXT NOT NULL DEFAULT '',
|
||||||
|
is_admin BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
gitea_user_id INTEGER NOT NULL UNIQUE,
|
||||||
|
gitea_token TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS boards (
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
project_owner TEXT NOT NULL,
|
project_owner TEXT NOT NULL,
|
||||||
@@ -61,6 +79,25 @@ def init_db():
|
|||||||
close_issue BOOLEAN NOT NULL DEFAULT 0,
|
close_issue BOOLEAN NOT NULL DEFAULT 0,
|
||||||
UNIQUE(board_id, column_name)
|
UNIQUE(board_id, column_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS checklists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
board_id INTEGER NOT NULL,
|
||||||
|
gitea_issue_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Checklist',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(board_id, gitea_issue_id, title)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS checklist_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
checked BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|||||||
+28
-4
@@ -6,10 +6,13 @@ import logging
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.routers import dashboard, board, notes, api
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
|
from app.routers import dashboard, board, notes, api, auth, webhooks
|
||||||
|
from app.services.gitea_client import gitea
|
||||||
|
|
||||||
# ── logging ──
|
# ── logging ──
|
||||||
|
|
||||||
@@ -23,12 +26,24 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="0.2.0",
|
version="0.3.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ponytail: CORS for LAN access
|
# ── middleware (order matters) ──
|
||||||
|
|
||||||
|
# Session (for OAuth2 state)
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=settings.app_secret_key,
|
||||||
|
max_age=3600, # 1 hour for OAuth state
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSRF protection
|
||||||
|
app.add_middleware(CSRFMiddleware)
|
||||||
|
|
||||||
|
# CORS for LAN access
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -38,10 +53,12 @@ app.add_middleware(
|
|||||||
|
|
||||||
# ── routers ──
|
# ── routers ──
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(board.router)
|
app.include_router(board.router)
|
||||||
app.include_router(notes.router)
|
app.include_router(notes.router)
|
||||||
app.include_router(api.router)
|
app.include_router(api.router)
|
||||||
|
app.include_router(webhooks.router)
|
||||||
|
|
||||||
# ── static ──
|
# ── static ──
|
||||||
|
|
||||||
@@ -51,4 +68,11 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
init_db()
|
init_db()
|
||||||
logger.info("FlowDeck started on port %d", settings.app_port)
|
# Seed admin user for non-OAuth mode
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("FlowDeck v0.3.0 started on port %d", settings.app_port)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""FlowDeck — Custom middleware."""
|
||||||
|
from app.middleware.csrf import CSRFMiddleware
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""FlowDeck — CSRF protection middleware."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Lightweight CSRF protection for state-changing requests.
|
||||||
|
|
||||||
|
All POST/PUT/PATCH/DELETE requests must include X-CSRF-Token
|
||||||
|
header matching the csrf_token cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
|
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback"}
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
# Webhook receiver and OAuth callback are exempt
|
||||||
|
if request.url.path in self.EXCLUDED_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
if request.method in self.SAFE_METHODS:
|
||||||
|
response = await call_next(request)
|
||||||
|
# Set CSRF cookie if not present
|
||||||
|
if "csrf_token" not in request.cookies:
|
||||||
|
response.set_cookie(
|
||||||
|
"csrf_token",
|
||||||
|
secrets.token_hex(32),
|
||||||
|
httponly=False, # Must be readable by JS
|
||||||
|
samesite="lax",
|
||||||
|
max_age=86400,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Validate CSRF for state-changing methods
|
||||||
|
csrf_cookie = request.cookies.get("csrf_token", "")
|
||||||
|
csrf_header = request.headers.get("X-CSRF-Token", "")
|
||||||
|
|
||||||
|
if not csrf_cookie or not csrf_header or not secrets.compare_digest(csrf_cookie, csrf_header):
|
||||||
|
return JSONResponse(
|
||||||
|
{"detail": "CSRF validation failed"},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
+290
-10
@@ -4,8 +4,9 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
@@ -14,6 +15,25 @@ from app.services.gitea_client import gitea
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["api"], prefix="/api")
|
router = APIRouter(tags=["api"], prefix="/api")
|
||||||
|
|
||||||
|
# ── Simple rate limiter (in-memory, per-IP) ──
|
||||||
|
_rate_limit_store: dict[str, tuple[float, int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(request: Request) -> bool:
|
||||||
|
"""Simple sliding window rate limiter. Returns True if allowed."""
|
||||||
|
if not settings.rate_limit_enabled:
|
||||||
|
return True
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
window_start, count = _rate_limit_store.get(ip, (0, 0))
|
||||||
|
if now - window_start > 60:
|
||||||
|
_rate_limit_store[ip] = (now, 1)
|
||||||
|
return True
|
||||||
|
if count >= settings.rate_limit_requests:
|
||||||
|
return False
|
||||||
|
_rate_limit_store[ip] = (window_start, count + 1)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
@@ -34,7 +54,7 @@ async def health():
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"db": db_ok,
|
"db": db_ok,
|
||||||
"gitea": gitea_ok,
|
"gitea": gitea_ok,
|
||||||
}
|
}
|
||||||
@@ -47,11 +67,13 @@ async def stats():
|
|||||||
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
||||||
card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"]
|
card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"]
|
||||||
note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"]
|
note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"]
|
||||||
|
user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"boards": board_count,
|
"boards": board_count,
|
||||||
"cards": card_count,
|
"cards": card_count,
|
||||||
"notes": note_count,
|
"notes": note_count,
|
||||||
|
"users": user_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -82,12 +104,16 @@ async def projects(search: str = Query(default=""), show_archived: bool = Query(
|
|||||||
|
|
||||||
@router.post("/move")
|
@router.post("/move")
|
||||||
async def move_card(
|
async def move_card(
|
||||||
|
request: Request,
|
||||||
owner: str = Query(...),
|
owner: str = Query(...),
|
||||||
repo: str = Query(...),
|
repo: str = Query(...),
|
||||||
issue_id: int = Query(...),
|
issue_id: int = Query(...),
|
||||||
column: str = Query(...),
|
column: str = Query(...),
|
||||||
):
|
):
|
||||||
"""Move a card to a column. Updates DB position + Gitea labels if mapped."""
|
"""Move a card to a column. Updates DB position + Gitea labels if mapped."""
|
||||||
|
if not _check_rate_limit(request):
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
board = conn.execute(
|
board = conn.execute(
|
||||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
@@ -98,7 +124,6 @@ async def move_card(
|
|||||||
|
|
||||||
board_id = board["id"]
|
board_id = board["id"]
|
||||||
|
|
||||||
# Upsert card
|
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
||||||
(board_id, issue_id),
|
(board_id, issue_id),
|
||||||
@@ -115,7 +140,6 @@ async def move_card(
|
|||||||
(board_id, issue_id, column),
|
(board_id, issue_id, column),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check label mapping for sync to Gitea
|
|
||||||
mapping = conn.execute(
|
mapping = conn.execute(
|
||||||
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
|
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
|
||||||
(board_id, column),
|
(board_id, column),
|
||||||
@@ -123,16 +147,13 @@ async def move_card(
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Sync to Gitea
|
|
||||||
if mapping:
|
if mapping:
|
||||||
try:
|
try:
|
||||||
if mapping["close_issue"]:
|
if mapping["close_issue"]:
|
||||||
await gitea.close_issue(owner, repo, issue_id)
|
await gitea.close_issue(owner, repo, issue_id)
|
||||||
else:
|
else:
|
||||||
# Get current labels, add mapped label
|
|
||||||
issue = await gitea.get_issue(owner, repo, issue_id)
|
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||||
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||||
# Remove other status labels, add new one
|
|
||||||
status_labels = await _get_status_labels(owner, repo, board_id)
|
status_labels = await _get_status_labels(owner, repo, board_id)
|
||||||
filtered = [l for l in current_labels if l not in status_labels]
|
filtered = [l for l in current_labels if l not in status_labels]
|
||||||
filtered.append(mapping["gitea_label"])
|
filtered.append(mapping["gitea_label"])
|
||||||
@@ -144,7 +165,6 @@ async def move_card(
|
|||||||
|
|
||||||
|
|
||||||
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
||||||
"""Get all status labels configured for this board."""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
|
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
|
||||||
@@ -185,7 +205,6 @@ async def set_col_mapping(
|
|||||||
|
|
||||||
@router.get("/board-config/{owner}/{repo}")
|
@router.get("/board-config/{owner}/{repo}")
|
||||||
async def get_board_config(owner: str, repo: str):
|
async def get_board_config(owner: str, repo: str):
|
||||||
"""Get board columns and WIP limits."""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
board = conn.execute(
|
board = conn.execute(
|
||||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
@@ -212,7 +231,6 @@ async def update_board_config(
|
|||||||
columns: str = Query(default=""),
|
columns: str = Query(default=""),
|
||||||
wip_limits: str = Query(default=""),
|
wip_limits: str = Query(default=""),
|
||||||
):
|
):
|
||||||
"""Update board columns and WIP limits."""
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
board = conn.execute(
|
board = conn.execute(
|
||||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
@@ -230,3 +248,265 @@ async def update_board_config(
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.5.0: Issue CRUD ──
|
||||||
|
|
||||||
|
@router.post("/issues/{owner}/{repo}")
|
||||||
|
async def create_issue(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str = Query(...),
|
||||||
|
body: str = Query(default=""),
|
||||||
|
labels: str = Query(default=""),
|
||||||
|
milestone: str = Query(default=""),
|
||||||
|
assignee: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Create a new issue in Gitea and add card to board."""
|
||||||
|
if not _check_rate_limit(request):
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||||
|
|
||||||
|
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
|
||||||
|
milestone_id = int(milestone) if milestone.strip().isdigit() else None
|
||||||
|
|
||||||
|
issue = await gitea.create_issue(
|
||||||
|
owner, repo, title, body,
|
||||||
|
labels=label_ids, milestone=milestone_id, assignee=assignee,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add card to local board
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if board:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, 'Backlog', 0)",
|
||||||
|
(board["id"], issue["number"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "issue": issue}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/issues/{owner}/{repo}/{issue_id}")
|
||||||
|
async def update_issue_api(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_id: int,
|
||||||
|
title: str = Query(default=""),
|
||||||
|
body: str = Query(default=""),
|
||||||
|
state: str = Query(default=""),
|
||||||
|
labels: str = Query(default=""),
|
||||||
|
milestone: str = Query(default=""),
|
||||||
|
assignee: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Update an issue (partial update)."""
|
||||||
|
if not _check_rate_limit(request):
|
||||||
|
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if title:
|
||||||
|
kwargs["title"] = title
|
||||||
|
if body:
|
||||||
|
kwargs["body"] = body
|
||||||
|
if state:
|
||||||
|
kwargs["state"] = state
|
||||||
|
if labels:
|
||||||
|
kwargs["labels"] = labels.split(",")
|
||||||
|
if milestone and milestone.strip().isdigit():
|
||||||
|
kwargs["milestone"] = int(milestone)
|
||||||
|
if assignee:
|
||||||
|
kwargs["assignees"] = [assignee]
|
||||||
|
|
||||||
|
issue = await gitea.update_issue(owner, repo, issue_id, **kwargs)
|
||||||
|
return {"status": "ok", "issue": issue}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.5.0: Card detail ──
|
||||||
|
|
||||||
|
@router.get("/issues/{owner}/{repo}/{issue_id}")
|
||||||
|
async def get_issue_detail(owner: str, repo: str, issue_id: int):
|
||||||
|
"""Get full issue details + comments."""
|
||||||
|
try:
|
||||||
|
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||||
|
comments = await gitea.get_issue_comments(owner, repo, issue_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
|
||||||
|
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
|
||||||
|
|
||||||
|
# Get checklists from local DB
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
board_id = board["id"] if board else None
|
||||||
|
|
||||||
|
checklists = []
|
||||||
|
if board_id:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM checklists WHERE board_id=? AND gitea_issue_id=? ORDER BY position",
|
||||||
|
(board_id, issue_id),
|
||||||
|
).fetchall()
|
||||||
|
for cl in rows:
|
||||||
|
items = conn.execute(
|
||||||
|
"SELECT * FROM checklist_items WHERE checklist_id=? ORDER BY position",
|
||||||
|
(cl["id"],),
|
||||||
|
).fetchall()
|
||||||
|
checklists.append({
|
||||||
|
**dict(cl),
|
||||||
|
"items": [dict(it) for it in items],
|
||||||
|
})
|
||||||
|
|
||||||
|
card = conn.execute(
|
||||||
|
"SELECT * FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
||||||
|
(board_id, issue_id),
|
||||||
|
).fetchone() if board_id else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue": issue,
|
||||||
|
"comments": comments,
|
||||||
|
"checklists": checklists,
|
||||||
|
"card": dict(card) if card else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.5.0: Checklists ──
|
||||||
|
|
||||||
|
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
||||||
|
async def create_checklist(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_id: int,
|
||||||
|
title: str = Query(default="Checklist"),
|
||||||
|
):
|
||||||
|
"""Create a new checklist for an issue."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=404, detail="Board not found")
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, ?, ?)",
|
||||||
|
(board["id"], issue_id, title),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "checklist_id": cur.lastrowid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
||||||
|
async def add_checklist_item(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_id: int,
|
||||||
|
checklist_id: int,
|
||||||
|
content: str = Query(...),
|
||||||
|
):
|
||||||
|
"""Add an item to a checklist."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO checklist_items (checklist_id, content) VALUES (?, ?)",
|
||||||
|
(checklist_id, content),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "item_id": cur.lastrowid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/checklist-items/{item_id}")
|
||||||
|
async def toggle_checklist_item(
|
||||||
|
item_id: int,
|
||||||
|
checked: bool = Query(default=False),
|
||||||
|
content: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Toggle or update a checklist item."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
if content:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE checklist_items SET checked=?, content=? WHERE id=?",
|
||||||
|
(int(checked), content, item_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE checklist_items SET checked=? WHERE id=?",
|
||||||
|
(int(checked), item_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/checklist-items/{item_id}")
|
||||||
|
async def delete_checklist_item(item_id: int):
|
||||||
|
"""Delete a checklist item."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/checklists/{checklist_id}")
|
||||||
|
async def delete_checklist(checklist_id: int):
|
||||||
|
"""Delete a checklist and all its items."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
||||||
|
conn.execute("DELETE FROM checklists WHERE id=?", (checklist_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.5.0: Card priority & due date ──
|
||||||
|
|
||||||
|
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||||
|
async def update_card(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
issue_id: int,
|
||||||
|
priority: str = Query(default=""),
|
||||||
|
due_date: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Update card metadata (priority, due date)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=404, detail="Board not found")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
if priority:
|
||||||
|
updates.append("priority=?")
|
||||||
|
params.append(priority)
|
||||||
|
if due_date:
|
||||||
|
updates.append("due_date=?")
|
||||||
|
params.append(due_date)
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
updates.append("updated_at=CURRENT_TIMESTAMP")
|
||||||
|
params.extend([board["id"], issue_id])
|
||||||
|
conn.execute(
|
||||||
|
f"""UPDATE cards SET {', '.join(updates)} WHERE board_id=? AND gitea_issue_id=?""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v0.5.0: Collaborators for assignee selector ──
|
||||||
|
|
||||||
|
@router.get("/collaborators/{owner}/{repo}")
|
||||||
|
async def get_collaborators(owner: str, repo: str):
|
||||||
|
"""Get repo collaborators (for assignee dropdown)."""
|
||||||
|
try:
|
||||||
|
collaborators = await gitea.get_collaborators(owner, repo)
|
||||||
|
return {"collaborators": collaborators}
|
||||||
|
except Exception as e:
|
||||||
|
return {"collaborators": [], "error": str(e)}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""FlowDeck — Auth routes: login, callback, logout."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Query
|
||||||
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
|
|
||||||
|
from app.auth.session import SessionManager
|
||||||
|
from app.auth.oauth import gitea_oauth
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login")
|
||||||
|
async def login(request: Request):
|
||||||
|
"""Redirect to Gitea OAuth2 authorize page."""
|
||||||
|
if not gitea_oauth.enabled:
|
||||||
|
# Fallback: use global token, create a fake session
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||||
|
if not user:
|
||||||
|
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
||||||
|
conn.commit()
|
||||||
|
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||||
|
user_data = dict(user)
|
||||||
|
session = SessionManager.create_session(user_data)
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||||
|
return response
|
||||||
|
|
||||||
|
state = secrets.token_hex(32)
|
||||||
|
request.session["oauth_state"] = state
|
||||||
|
auth_url = gitea_oauth.get_authorize_url(state)
|
||||||
|
return RedirectResponse(url=auth_url, status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def callback(
|
||||||
|
request: Request,
|
||||||
|
code: str = Query(...),
|
||||||
|
state: str = Query(...),
|
||||||
|
):
|
||||||
|
"""Handle OAuth2 callback from Gitea."""
|
||||||
|
# Validate state
|
||||||
|
expected_state = request.session.get("oauth_state", "")
|
||||||
|
if not expected_state or state != expected_state:
|
||||||
|
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||||
|
|
||||||
|
# Exchange code for token
|
||||||
|
token_data = await gitea_oauth.exchange_code(code)
|
||||||
|
if not token_data:
|
||||||
|
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||||
|
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user = await gitea_oauth.get_user(access_token)
|
||||||
|
if not user:
|
||||||
|
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||||
|
|
||||||
|
# Store user in DB
|
||||||
|
from app.db import get_conn
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO users (login, full_name, email, avatar_url)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(login)
|
||||||
|
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
||||||
|
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Store token
|
||||||
|
SessionManager.store_token(user["id"], access_token)
|
||||||
|
|
||||||
|
# Also store user in local DB
|
||||||
|
with get_conn() as conn:
|
||||||
|
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
||||||
|
user_data = dict(db_user) if db_user else user
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session = SessionManager.create_session(user_data)
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
"""Clear session and redirect to dashboard."""
|
||||||
|
response = RedirectResponse(url="/", status_code=302)
|
||||||
|
response.delete_cookie("flowdeck_session")
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user")
|
||||||
|
async def current_user(request: Request):
|
||||||
|
"""Return current user info as JSON."""
|
||||||
|
from app.auth.session import get_current_user as gcu
|
||||||
|
user = await gcu(request)
|
||||||
|
if not user:
|
||||||
|
return {"authenticated": False}
|
||||||
|
return {"authenticated": True, "user": user}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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 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"):
|
||||||
|
# Add card to default column
|
||||||
|
column = "Backlog"
|
||||||
|
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)}
|
||||||
+115
-32
@@ -35,7 +35,6 @@ class GiteaClient:
|
|||||||
# ── repos ──
|
# ── repos ──
|
||||||
|
|
||||||
async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]:
|
async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]:
|
||||||
"""List user repos (sorted by updated desc)."""
|
|
||||||
cache_key = f"repos:{page}:{limit}"
|
cache_key = f"repos:{page}:{limit}"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -53,7 +52,6 @@ class GiteaClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]:
|
async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]:
|
||||||
"""List repos for an org."""
|
|
||||||
cache_key = f"org_repos:{org}:{page}:{limit}"
|
cache_key = f"org_repos:{org}:{page}:{limit}"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -71,7 +69,6 @@ class GiteaClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_user_orgs(self) -> list[dict]:
|
async def get_user_orgs(self) -> list[dict]:
|
||||||
"""List user orgs."""
|
|
||||||
cache_key = "user_orgs"
|
cache_key = "user_orgs"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -91,7 +88,6 @@ class GiteaClient:
|
|||||||
async def get_issues(
|
async def get_issues(
|
||||||
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
|
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List issues for a repo."""
|
|
||||||
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
|
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -109,7 +105,6 @@ class GiteaClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
"""Get a single issue."""
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
@@ -118,40 +113,63 @@ class GiteaClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
async def create_issue(
|
||||||
|
self, owner: str, repo: str, title: str, body: str = "",
|
||||||
|
labels: list[int] | None = None, milestone: int | None = None,
|
||||||
|
assignee: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Create a new issue in Gitea."""
|
||||||
|
payload: dict[str, Any] = {"title": title, "body": body}
|
||||||
|
if labels:
|
||||||
|
payload["labels"] = labels
|
||||||
|
if milestone:
|
||||||
|
payload["milestone"] = milestone
|
||||||
|
if assignee:
|
||||||
|
payload["assignees"] = [assignee]
|
||||||
|
|
||||||
|
self._invalidate_issue_cache(owner, repo)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||||
|
headers=self._headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def update_issue(
|
||||||
|
self, owner: str, repo: str, issue_id: int, **kwargs
|
||||||
|
) -> dict:
|
||||||
|
"""Update an issue (title, body, state, labels, milestone, assignees)."""
|
||||||
|
self._invalidate_issue_cache(owner, repo)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
json=kwargs,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
async def update_issue_labels(
|
async def update_issue_labels(
|
||||||
self, owner: str, repo: str, issue_id: int, labels: list[str]
|
self, owner: str, repo: str, issue_id: int, labels: list[str]
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Replace issue labels."""
|
return await self.update_issue(owner, repo, issue_id, labels=labels)
|
||||||
self._invalidate_issue_cache(owner, repo)
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.put(
|
|
||||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
|
||||||
headers=self._headers,
|
|
||||||
json={"labels": labels},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
"""Close an issue (move to Terminé)."""
|
return await self.update_issue(owner, repo, issue_id, state="closed")
|
||||||
self._invalidate_issue_cache(owner, repo)
|
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
|
||||||
resp = await client.patch(
|
|
||||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
|
||||||
headers=self._headers,
|
|
||||||
json={"state": "closed"},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
"""Reopen an issue."""
|
return await self.update_issue(owner, repo, issue_id, state="open")
|
||||||
self._invalidate_issue_cache(owner, repo)
|
|
||||||
|
async def get_issue_comments(
|
||||||
|
self, owner: str, repo: str, issue_id: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Get comments for an issue."""
|
||||||
async with httpx.AsyncClient(timeout=10) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
resp = await client.patch(
|
resp = await client.get(
|
||||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
|
||||||
headers=self._headers,
|
headers=self._headers,
|
||||||
json={"state": "open"},
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
return resp.json()
|
||||||
@@ -165,7 +183,6 @@ class GiteaClient:
|
|||||||
# ── labels ──
|
# ── labels ──
|
||||||
|
|
||||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||||
"""List repo labels."""
|
|
||||||
cache_key = f"labels:{owner}:{repo}"
|
cache_key = f"labels:{owner}:{repo}"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -186,7 +203,6 @@ class GiteaClient:
|
|||||||
async def get_milestones(
|
async def get_milestones(
|
||||||
self, owner: str, repo: str, state: str = "open"
|
self, owner: str, repo: str, state: str = "open"
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""List repo milestones."""
|
|
||||||
cache_key = f"milestones:{owner}:{repo}:{state}"
|
cache_key = f"milestones:{owner}:{repo}:{state}"
|
||||||
cached = self._cached(cache_key)
|
cached = self._cached(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -203,6 +219,73 @@ class GiteaClient:
|
|||||||
self._set_cache(cache_key, data)
|
self._set_cache(cache_key, data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
# ── webhooks ──
|
||||||
|
|
||||||
|
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||||
|
"""List webhooks for a repo."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def create_webhook(
|
||||||
|
self, owner: str, repo: str, url: str, secret: str,
|
||||||
|
events: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a webhook for a repo."""
|
||||||
|
if events is None:
|
||||||
|
events = ["issues", "pull_request", "repository", "issue_comment"]
|
||||||
|
payload = {
|
||||||
|
"type": "gitea",
|
||||||
|
"config": {
|
||||||
|
"url": url,
|
||||||
|
"content_type": "json",
|
||||||
|
"secret": secret,
|
||||||
|
},
|
||||||
|
"events": events,
|
||||||
|
"active": True,
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||||
|
headers=self._headers,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
|
||||||
|
"""Delete a webhook."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.delete(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
return resp.status_code == 204
|
||||||
|
|
||||||
|
# ── assignees/collaborators ──
|
||||||
|
|
||||||
|
async def get_collaborators(self, owner: str, repo: str) -> list[dict]:
|
||||||
|
"""List repo collaborators."""
|
||||||
|
cache_key = f"collaborators:{owner}:{repo}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/collaborators",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
# ── singleton ──
|
# ── singleton ──
|
||||||
|
|
||||||
gitea = GiteaClient()
|
gitea = GiteaClient()
|
||||||
|
|||||||
@@ -18,12 +18,27 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
<span id="user-info" hx-get="/auth/user" hx-trigger="load" hx-swap="innerHTML">
|
||||||
|
<a href="/auth/login" class="btn btn-sm">🔑 Login</a>
|
||||||
|
</span>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
|
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Card Detail Modal (global) -->
|
||||||
|
<div id="card-modal" class="modal-overlay" style="display:none" hx-target="#card-modal-content">
|
||||||
|
<div class="modal-container">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="card-modal-title">Détail</h3>
|
||||||
|
<button onclick="closeModal()" class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="card-modal-content" class="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/app.js" defer></script>
|
<script src="/static/js/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+283
-21
@@ -6,7 +6,10 @@
|
|||||||
repo: '{{ repo }}',
|
repo: '{{ repo }}',
|
||||||
milestone: '{{ current_milestone }}',
|
milestone: '{{ current_milestone }}',
|
||||||
label: '{{ current_label }}',
|
label: '{{ current_label }}',
|
||||||
assignee: '{{ current_assignee }}'
|
assignee: '{{ current_assignee }}',
|
||||||
|
showNewIssue: false,
|
||||||
|
showDetail: false,
|
||||||
|
detailIssueId: 0
|
||||||
}">
|
}">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -19,29 +22,84 @@
|
|||||||
<strong>{{ repo }}</strong>
|
<strong>{{ repo }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="board-actions">
|
<div class="board-actions">
|
||||||
|
<button class="btn btn-sm" onclick="registerWebhook('{{ owner }}', '{{ repo }}')" title="Enregistrer webhook">🔔 Webhook</button>
|
||||||
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
|
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
|
||||||
<a href="{{ settings.gitea_url }}/{{ owner }}/{{ repo }}/issues/new" target="_blank" class="btn btn-sm btn-primary">+ Issue</a>
|
<button class="btn btn-sm btn-primary" @click="showNewIssue = !showNewIssue">+ Issue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New Issue Form -->
|
||||||
|
<div class="new-issue-form" x-show="showNewIssue" x-transition>
|
||||||
|
<h4>Nouvelle issue dans {{ owner }}/{{ repo }}</h4>
|
||||||
|
<form id="new-issue-form" hx-post="/api/issues/{{ owner }}/{{ repo }}"
|
||||||
|
hx-target=".board-columns" hx-swap="outerHTML"
|
||||||
|
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']"
|
||||||
|
hx-on--after-request="showNewIssue=false; this.reset()">
|
||||||
|
<input type="text" name="title" placeholder="Titre de l'issue..." required class="form-input">
|
||||||
|
<textarea name="body" placeholder="Description (markdown)..." rows="3" class="form-input"></textarea>
|
||||||
|
<div class="form-row">
|
||||||
|
<select name="labels" class="form-select">
|
||||||
|
<option value="">Label</option>
|
||||||
|
{% for lbl in labels %}
|
||||||
|
<option value="{{ lbl.id }}">🏷 {{ lbl.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="milestone" class="form-select">
|
||||||
|
<option value="">Milestone</option>
|
||||||
|
{% for m in milestones %}
|
||||||
|
<option value="{{ m.id }}">📅 {{ m.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" name="assignee" placeholder="Assigné à (login)" class="form-input" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Créer</button>
|
||||||
|
<button type="button" class="btn" @click="showNewIssue = false">Annuler</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="board-filters">
|
<div class="board-filters">
|
||||||
<select x-model="milestone" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
<select class="filter-select" name="milestone_filter"
|
||||||
|
hx-get="/board/{{ owner }}/{{ repo }}"
|
||||||
|
hx-target=".board-columns"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="[name='label_filter'],[name='assignee_filter']"
|
||||||
|
hx-swap="outerHTML">
|
||||||
<option value="">Tous les milestones</option>
|
<option value="">Tous les milestones</option>
|
||||||
{% for m in milestones %}
|
{% for m in milestones %}
|
||||||
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
|
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select x-model="label" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
<select class="filter-select" name="label_filter"
|
||||||
|
hx-get="/board/{{ owner }}/{{ repo }}"
|
||||||
|
hx-target=".board-columns"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-include="[name='milestone_filter'],[name='assignee_filter']"
|
||||||
|
hx-swap="outerHTML">
|
||||||
<option value="">Tous les labels</option>
|
<option value="">Tous les labels</option>
|
||||||
{% for lbl in labels %}
|
{% for lbl in labels %}
|
||||||
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
|
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
<input type="text" name="assignee_filter" placeholder="Assigné à..."
|
||||||
|
class="filter-input"
|
||||||
|
hx-get="/board/{{ owner }}/{{ repo }}"
|
||||||
|
hx-target=".board-columns"
|
||||||
|
hx-trigger="keyup changed delay:500ms"
|
||||||
|
hx-include="[name='milestone_filter'],[name='label_filter']"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
value="{{ current_assignee }}">
|
||||||
|
<button class="btn btn-sm" hx-get="/board/{{ owner }}/{{ repo }}"
|
||||||
|
hx-target=".board-columns" hx-swap="outerHTML"
|
||||||
|
hx-include="[name='milestone_filter'],[name='label_filter'],[name='assignee_filter']">
|
||||||
|
🔄 Rafraîchir
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Columns -->
|
<!-- Columns -->
|
||||||
<div class="board-columns"
|
<div class="board-columns" id="board-columns"
|
||||||
hx-trigger="cardMoved from:body"
|
hx-trigger="cardMoved from:body"
|
||||||
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
|
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
|
||||||
hx-target=".board-columns"
|
hx-target=".board-columns"
|
||||||
@@ -62,15 +120,16 @@
|
|||||||
<div class="card"
|
<div class="card"
|
||||||
data-issue-id="{{ issue.number }}"
|
data-issue-id="{{ issue.number }}"
|
||||||
data-column="{{ col }}"
|
data-column="{{ col }}"
|
||||||
draggable="true">
|
draggable="true"
|
||||||
|
ondblclick="openCardDetail('{{ owner }}','{{ repo }}',{{ issue.number }})">
|
||||||
<div class="card-labels">
|
<div class="card-labels">
|
||||||
{% for lbl in issue.labels %}
|
{% for lbl in issue.labels %}
|
||||||
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
|
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<a href="{{ issue.html_url }}" target="_blank">#{{ issue.number }}</a>
|
<a href="{{ issue.html_url }}" target="_blank" onclick="event.stopPropagation()">#{{ issue.number }}</a>
|
||||||
{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}
|
<span class="card-title-text">{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
{% if issue.assignee %}
|
{% if issue.assignee %}
|
||||||
@@ -97,13 +156,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PRs section -->
|
||||||
|
{% if prs %}
|
||||||
|
<div class="prs-section">
|
||||||
|
<h3>🔄 Pull Requests ({{ prs|length }})</h3>
|
||||||
|
<div class="prs-list">
|
||||||
|
{% for pr in prs %}
|
||||||
|
<div class="pr-card">
|
||||||
|
<a href="{{ pr.html_url }}" target="_blank" class="pr-title">#{{ pr.number }} {{ pr.title[:80] }}</a>
|
||||||
|
<div class="pr-meta">
|
||||||
|
{% if pr.user %}<span>{{ pr.user.login }}</span>{% endif %}
|
||||||
|
<span>{{ pr.state }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ponytail: initialize SortableJS on each column
|
// Initialize SortableJS
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', initSortable);
|
||||||
|
document.addEventListener('htmx:afterSwap', initSortable);
|
||||||
|
|
||||||
|
function initSortable() {
|
||||||
document.querySelectorAll('.sortable').forEach(el => {
|
document.querySelectorAll('.sortable').forEach(el => {
|
||||||
new Sortable(el, {
|
if (el._sortable) return;
|
||||||
|
el._sortable = new Sortable(el, {
|
||||||
group: 'kanban',
|
group: 'kanban',
|
||||||
animation: 150,
|
animation: 150,
|
||||||
ghostClass: 'card-ghost',
|
ghostClass: 'card-ghost',
|
||||||
@@ -114,15 +195,13 @@
|
|||||||
const owner = evt.to.dataset.owner;
|
const owner = evt.to.dataset.owner;
|
||||||
const repo = evt.to.dataset.repo;
|
const repo = evt.to.dataset.repo;
|
||||||
|
|
||||||
// Optimistic: move card in DOM is already done
|
const csrfToken = getCSRFToken();
|
||||||
|
|
||||||
// Persist to backend
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`, {
|
const resp = await fetch(
|
||||||
method: 'POST'
|
`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`,
|
||||||
});
|
{ method: 'POST', headers: { 'X-CSRF-Token': csrfToken } }
|
||||||
|
);
|
||||||
// Update column counts
|
if (!resp.ok) throw new Error('Move failed');
|
||||||
document.querySelectorAll('.board-column').forEach(col => {
|
document.querySelectorAll('.board-column').forEach(col => {
|
||||||
const count = col.querySelectorAll('.card').length;
|
const count = col.querySelectorAll('.card').length;
|
||||||
const badge = col.querySelector('.column-count');
|
const badge = col.querySelector('.column-count');
|
||||||
@@ -130,12 +209,195 @@
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Move failed:', e);
|
console.error('Move failed:', e);
|
||||||
// ponytail: reload on error
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function getCSRFToken() {
|
||||||
|
const match = document.cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCardDetail(owner, repo, issueId) {
|
||||||
|
const modal = document.getElementById('card-modal');
|
||||||
|
const content = document.getElementById('card-modal-content');
|
||||||
|
content.innerHTML = '<div class="loading">Chargement...</div>';
|
||||||
|
document.getElementById('card-modal-title').textContent = `#${issueId} — ${owner}/${repo}`;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
fetch(`/api/issues/${owner}/${repo}/${issueId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
content.innerHTML = renderCardDetail(owner, repo, data);
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
content.innerHTML = `<p class="error">Erreur: ${e}</p>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('card-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCardDetail(owner, repo, data) {
|
||||||
|
const issue = data.issue || {};
|
||||||
|
const comments = data.comments || [];
|
||||||
|
const checklists = data.checklists || [];
|
||||||
|
const card = data.card || {};
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Issue header
|
||||||
|
html += `<div class="detail-section">`;
|
||||||
|
html += `<span class="detail-state badge ${issue.state}">${issue.state}</span> `;
|
||||||
|
html += `<strong>${escHtml(issue.title || '')}</strong>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
html += `<div class="detail-meta">`;
|
||||||
|
const labels = issue.labels || [];
|
||||||
|
if (labels.length) html += labels.map(l => `<span class="card-label" style="background:#${l.color || '666'}33;color:#${l.color || 'ccc'}">${escHtml(l.name)}</span>`).join(' ');
|
||||||
|
if (issue.milestone) html += `<span>📅 ${escHtml(issue.milestone.title)}</span>`;
|
||||||
|
if (issue.assignee) html += `<span>👤 ${escHtml(issue.assignee.login)}</span>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Priority + Due date (editable)
|
||||||
|
html += `<div class="detail-editable">`;
|
||||||
|
html += `<label>Priorité: <select onchange="updateCardMeta('${owner}','${repo}',${issue.number},this.value,'')" class="form-select-sm">`;
|
||||||
|
['Low','Medium','High','Urgent'].forEach(p => {
|
||||||
|
html += `<option value="${p}" ${(card.priority||'Medium')===p?'selected':''}>${p}</option>`;
|
||||||
|
});
|
||||||
|
html += `</select></label> `;
|
||||||
|
html += `<label>Échéance: <input type="date" value="${card.due_date||''}" onchange="updateCardMeta('${owner}','${repo}',${issue.number},'',this.value)" class="form-input-sm"></label>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Description
|
||||||
|
html += `<div class="detail-section">`;
|
||||||
|
html += `<h4>Description</h4>`;
|
||||||
|
html += `<div class="markdown-body">${escHtml(issue.body || '')}</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Checklists
|
||||||
|
html += `<div class="detail-section">`;
|
||||||
|
html += `<h4>Checklists</h4>`;
|
||||||
|
checklists.forEach(cl => {
|
||||||
|
html += `<div class="checklist">`;
|
||||||
|
html += `<strong>${escHtml(cl.title)}</strong>`;
|
||||||
|
html += `<ul class="checklist-items">`;
|
||||||
|
(cl.items || []).forEach(item => {
|
||||||
|
html += `<li>`;
|
||||||
|
html += `<input type="checkbox" ${item.checked?'checked':''} onchange="toggleCheckItem(${item.id},this.checked)" class="checklist-check">`;
|
||||||
|
html += `<span class="${item.checked?'checked':''}">${escHtml(item.content)}</span>`;
|
||||||
|
html += `<button onclick="deleteCheckItem(${item.id})" class="btn-del" title="Supprimer">×</button>`;
|
||||||
|
html += `</li>`;
|
||||||
|
});
|
||||||
|
html += `</ul>`;
|
||||||
|
html += `<div class="add-item">`;
|
||||||
|
html += `<input type="text" id="new-item-${cl.id}" placeholder="Nouvelle tâche..." class="form-input-sm" onkeydown="if(event.key==='Enter')addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})">`;
|
||||||
|
html += `<button onclick="addCheckItem(${cl.id},'${owner}','${repo}',${issue.number})" class="btn btn-sm">+</button>`;
|
||||||
|
html += `</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
if (!checklists.length) {
|
||||||
|
html += `<button onclick="createChecklist('${owner}','${repo}',${issue.number})" class="btn btn-sm">+ Nouvelle checklist</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
html += `<div class="detail-section">`;
|
||||||
|
html += `<h4>Commentaires (${comments.length})</h4>`;
|
||||||
|
comments.forEach(c => {
|
||||||
|
html += `<div class="comment">`;
|
||||||
|
html += `<img src="${c.user?.avatar_url||''}" width="24" height="24" class="avatar">`;
|
||||||
|
html += `<strong>${escHtml(c.user?.login||'')}</strong>`;
|
||||||
|
html += `<span class="comment-date">${(c.created_at||'').substr(0,10)}</span>`;
|
||||||
|
html += `<div class="comment-body">${escHtml(c.body||'')}</div>`;
|
||||||
|
html += `</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
html += `<div class="detail-actions">`;
|
||||||
|
html += `<a href="${issue.html_url||''}" target="_blank" class="btn btn-sm">🔗 Ouvrir dans Gitea</a>`;
|
||||||
|
html += `<button onclick="editIssueInline('${owner}','${repo}',${issue.number})" class="btn btn-sm">✏️ Éditer</button>`;
|
||||||
|
html += `<button onclick="closeModal()" class="btn btn-sm">Fermer</button>`;
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
async function updateCardMeta(owner, repo, issueId, priority, dueDate) {
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/card/${owner}/${repo}/${issueId}?priority=${priority}&due_date=${dueDate}`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCheckItem(itemId, checked) {
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/checklist-items/${itemId}?checked=${checked}`, {
|
||||||
|
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCheckItem(itemId) {
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/checklist-items/${itemId}`, {
|
||||||
|
method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
// Refresh modal
|
||||||
|
const parts = document.getElementById('card-modal-title').textContent.split(' — ');
|
||||||
|
const issueNum = parts[0].replace('#','');
|
||||||
|
const ownerRepo = parts[1] || '';
|
||||||
|
const [ow, rp] = ownerRepo.split('/');
|
||||||
|
if (ow && rp) openCardDetail(ow, rp, parseInt(issueNum));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCheckItem(checklistId, owner, repo, issueId) {
|
||||||
|
const input = document.getElementById(`new-item-${checklistId}`);
|
||||||
|
if (!input || !input.value.trim()) return;
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/checklist-items/${owner}/${repo}/${issueId}/${checklistId}?content=${encodeURIComponent(input.value)}`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
openCardDetail(owner, repo, issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChecklist(owner, repo, issueId) {
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/checklists/${owner}/${repo}/${issueId}`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
openCardDetail(owner, repo, issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editIssueInline(owner, repo, issueId) {
|
||||||
|
const newTitle = prompt('Nouveau titre:', '');
|
||||||
|
if (!newTitle) return;
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
await fetch(`/api/issues/${owner}/${repo}/${issueId}?title=${encodeURIComponent(newTitle)}`, {
|
||||||
|
method: 'PATCH', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerWebhook(owner, repo) {
|
||||||
|
const csrfToken = getCSRFToken();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/webhook/register/${owner}/${repo}`, {
|
||||||
|
method: 'POST', headers: { 'X-CSRF-Token': csrfToken }
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
alert(data.status === 'already_registered' ? 'Webhook déjà enregistré ✅' : 'Webhook enregistré ✅');
|
||||||
|
} catch(e) {
|
||||||
|
alert('Erreur: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ aiosqlite==0.20.*
|
|||||||
loguru==0.7.*
|
loguru==0.7.*
|
||||||
python-dotenv==1.0.*
|
python-dotenv==1.0.*
|
||||||
packaging>=24.0
|
packaging>=24.0
|
||||||
|
itsdangerous==2.2.*
|
||||||
|
slowapi==0.1.*
|
||||||
|
|||||||
@@ -159,10 +159,95 @@ body { min-height: 100vh; }
|
|||||||
.btn-primary:hover { opacity: 0.9; }
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Modal ── */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
}
|
||||||
|
.modal-container {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; width: 90%; max-width: 640px; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-header h3 { font-size: 15px; }
|
||||||
|
.modal-close { background: none; border: none; color: var(--text-dim); font-size: 22px; cursor: pointer; }
|
||||||
|
.modal-body { padding: 16px; overflow-y: auto; flex: 1; }
|
||||||
|
|
||||||
|
/* ── Forms ── */
|
||||||
|
.new-issue-form {
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.new-issue-form h4 { font-size: 14px; margin-bottom: 8px; }
|
||||||
|
.form-input, .form-select {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 8px 12px; color: var(--text); font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.form-input:focus, .form-select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.form-input-sm { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 4px 6px; color: var(--text); font-size: 12px; }
|
||||||
|
.form-select-sm { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; color: var(--text); font-size: 12px; }
|
||||||
|
.form-row { display: flex; gap: 8px; }
|
||||||
|
.form-actions { display: flex; gap: 8px; margin-top: 4px; }
|
||||||
|
.filter-input {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 4px 8px; color: var(--text); font-size: 13px; width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card Detail ── */
|
||||||
|
.detail-section { margin-bottom: 16px; }
|
||||||
|
.detail-section h4 { font-size: 13px; color: var(--text-dim); margin-bottom: 8px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
||||||
|
.detail-meta { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.detail-editable { display: flex; gap: 12px; margin-bottom: 12px; font-size: 12px; align-items: center; }
|
||||||
|
.detail-actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||||||
|
.detail-state { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; }
|
||||||
|
.detail-state.open { background: var(--green); color: white; }
|
||||||
|
.detail-state.closed { background: var(--red); color: white; }
|
||||||
|
|
||||||
|
/* ── Comments ── */
|
||||||
|
.comment {
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.comment-date { font-size: 11px; color: var(--text-dim); }
|
||||||
|
.comment-body { width: 100%; font-size: 13px; padding: 4px 0; }
|
||||||
|
|
||||||
|
/* ── Checklists ── */
|
||||||
|
.checklist { margin-bottom: 12px; }
|
||||||
|
.checklist-items { list-style: none; display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
|
||||||
|
.checklist-items li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||||
|
.checklist-check { cursor: pointer; }
|
||||||
|
.checklist-items .checked { text-decoration: line-through; color: var(--text-dim); }
|
||||||
|
.btn-del { background: none; border: none; color: var(--red); cursor: pointer; font-size: 16px; }
|
||||||
|
.add-item { display: flex; gap: 4px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ── PRs section ── */
|
||||||
|
.prs-section { padding: 12px 16px; border-top: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.prs-section h3 { font-size: 14px; margin-bottom: 8px; }
|
||||||
|
.prs-list { display: flex; flex-direction: column; gap: 4px; max-height: 120px; overflow-y: auto; }
|
||||||
|
.pr-card { padding: 6px 0; font-size: 13px; }
|
||||||
|
.pr-title { color: var(--accent); text-decoration: none; }
|
||||||
|
.pr-meta { font-size: 11px; color: var(--text-dim); display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
/* ── Markdown body ── */
|
||||||
|
.markdown-body { font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ── Loading ── */
|
||||||
|
.loading { text-align: center; padding: 24px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── User info ── */
|
||||||
|
#user-info { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
#user-info .avatar { width: 20px; height: 20px; }
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.board-columns { flex-direction: column; }
|
.board-columns { flex-direction: column; }
|
||||||
.board-column { max-width: 100%; min-height: 200px; }
|
.board-column { max-width: 100%; min-height: 200px; }
|
||||||
.dashboard-header { flex-direction: column; align-items: flex-start; }
|
.dashboard-header { flex-direction: column; align-items: flex-start; }
|
||||||
.search-input { width: 100%; }
|
.search-input { width: 100%; }
|
||||||
|
.modal-container { width: 95%; max-height: 90vh; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user