v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
CI / test (push) Failing after 5s
CI / lint (push) Failing after 4s

- 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:
2026-07-08 09:23:57 -04:00
parent 87862b64b8
commit 5a124d1328
17 changed files with 1390 additions and 91 deletions
+37 -24
View File
@@ -1,32 +1,45 @@
# Changelog
## [0.2.0] — 2026-07-08 — "FlowDeck"
## [0.3.0] — 2026-07-08 — "Auth + Card Detail + Webhooks"
### Added
- Renamed from Kanbante to FlowDeck
- Dashboard listant tous les projets Gitea (user + orgs)
- Board Kanban 5 colonnes : Backlog, À faire, En cours, Révision, Terminé
- Issues Gitea → cartes avec filtres (milestone, label, assignee)
- Drag & drop (SortableJS) avec persistance SQLite
- Sync bidirectionnelle : colonne → label Gitea, Terminé → close issue
- Colonnes personnalisables + mapping colonne→label
- WIP limits configurables
- Notes Markdown par projet
- API REST v1 : /api/health, /api/stats, /api/projects, /api/move, /api/col-mapping
- Thème clair/sombre avec persistance localStorage
- Cache Gitea in-memory avec TTL
- Docker + docker-compose
- Health check enrichi (DB + Gitea)
- Filtres intra-board (milestone, label, assignee)
- Pull requests affichées séparément
- Priorités (Low/Medium/High/Urgent) + dates d'échéance
- Architecture documentée (ARCHITECTURE.md)
- Roadmap (ROADMAP.md)
### Added — Auth & Multi-user (v0.3.0)
- OAuth2 Gitea (login via Gitea) — /auth/login, /auth/callback, /auth/logout
- Token par utilisateur stocké en BDD (user_tokens table)
- Session signed cookies (itsdangerous) — 7 jours
- CSRF protection middleware sur tous les POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min par IP, configurable)
- Fallback mode sans OAuth: connexion automatique en tant qu'admin
### Added — Issue Creation & Editing (v0.5.0 partial)
- Création d'issues depuis FlowDeck (POST /api/issues/{owner}/{repo})
- Formulaire inline avec labels, milestone, assignee
- Édition de titre inline (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card metadata: priority + due date (POST /api/card/{owner}/{repo}/{issue_id})
### Added — Webhooks (v0.4.0)
- Endpoint webhook receiver: POST /api/webhook
- Signature HMAC-SHA256 verification
- Gère: issue opened/closed/labeled/deleted, PR, repository
- Auto-registration: POST /api/webhook/register/{owner}/{repo}
- 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
- FastAPI + Uvicorn + httpx + Jinja2 + HTMX + Alpine.js + SortableJS
- SQLite WAL mode
- Python 3.12-slim Docker
- + itsdangerous (signed sessions)
- + slowapi (rate limiting, optional)
- + 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é)
- MVP initial
+3
View File
@@ -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
+77
View File
@@ -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()
+68
View File
@@ -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
+10
View File
@@ -17,6 +17,12 @@ class Settings(BaseSettings):
gitea_oauth_client_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_secret_key: str = "change-me-to-random"
app_host: str = "0.0.0.0"
@@ -24,6 +30,10 @@ class Settings(BaseSettings):
log_level: str = "INFO"
default_lang: str = "fr"
# Rate limiting
rate_limit_enabled: bool = True
rate_limit_requests: int = 60 # per minute
# Database
database_url: str = "sqlite:////data/flowdeck.db"
+37
View File
@@ -20,6 +20,24 @@ def init_db():
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_owner TEXT NOT NULL,
@@ -61,6 +79,25 @@ def init_db():
close_issue BOOLEAN NOT NULL DEFAULT 0,
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()
+28 -4
View File
@@ -6,10 +6,13 @@ import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
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 ──
@@ -23,12 +26,24 @@ logger = logging.getLogger(__name__)
app = FastAPI(
title="FlowDeck",
version="0.2.0",
version="0.3.0",
docs_url="/docs" if settings.log_level == "DEBUG" else 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(
CORSMiddleware,
allow_origins=["*"],
@@ -38,10 +53,12 @@ app.add_middleware(
# ── routers ──
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
app.include_router(api.router)
app.include_router(webhooks.router)
# ── static ──
@@ -51,4 +68,11 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.on_event("startup")
async def startup():
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)
+2
View File
@@ -0,0 +1,2 @@
"""FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware
+49
View File
@@ -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
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import json
import logging
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.db import get_conn
@@ -14,6 +15,25 @@ from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
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")
async def health():
@@ -34,7 +54,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "0.2.0",
"version": "0.3.0",
"db": db_ok,
"gitea": gitea_ok,
}
@@ -47,11 +67,13 @@ async def stats():
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").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"]
user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"]
return {
"boards": board_count,
"cards": card_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")
async def move_card(
request: Request,
owner: str = Query(...),
repo: str = Query(...),
issue_id: int = Query(...),
column: str = Query(...),
):
"""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:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
@@ -98,7 +124,6 @@ async def move_card(
board_id = board["id"]
# Upsert card
existing = conn.execute(
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
(board_id, issue_id),
@@ -115,7 +140,6 @@ async def move_card(
(board_id, issue_id, column),
)
# Check label mapping for sync to Gitea
mapping = conn.execute(
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
(board_id, column),
@@ -123,16 +147,13 @@ async def move_card(
conn.commit()
# Sync to Gitea
if mapping:
try:
if mapping["close_issue"]:
await gitea.close_issue(owner, repo, issue_id)
else:
# Get current labels, add mapped label
issue = await gitea.get_issue(owner, repo, issue_id)
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)
filtered = [l for l in current_labels if l not in status_labels]
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]:
"""Get all status labels configured for this board."""
with get_conn() as conn:
rows = conn.execute(
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
@@ -185,7 +205,6 @@ async def set_col_mapping(
@router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
"""Get board columns and WIP limits."""
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
@@ -212,7 +231,6 @@ async def update_board_config(
columns: str = Query(default=""),
wip_limits: str = Query(default=""),
):
"""Update board columns and WIP limits."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
@@ -230,3 +248,265 @@ async def update_board_config(
)
conn.commit()
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)}
+110
View File
@@ -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}
+179
View File
@@ -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
View File
@@ -35,7 +35,6 @@ class GiteaClient:
# ── repos ──
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}"
cached = self._cached(cache_key)
if cached:
@@ -53,7 +52,6 @@ class GiteaClient:
return data
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}"
cached = self._cached(cache_key)
if cached:
@@ -71,7 +69,6 @@ class GiteaClient:
return data
async def get_user_orgs(self) -> list[dict]:
"""List user orgs."""
cache_key = "user_orgs"
cached = self._cached(cache_key)
if cached:
@@ -91,7 +88,6 @@ class GiteaClient:
async def get_issues(
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
) -> list[dict]:
"""List issues for a repo."""
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
cached = self._cached(cache_key)
if cached:
@@ -109,7 +105,6 @@ class GiteaClient:
return data
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:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
@@ -118,40 +113,63 @@ class GiteaClient:
resp.raise_for_status()
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(
self, owner: str, repo: str, issue_id: int, labels: list[str]
) -> dict:
"""Replace issue 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()
return await self.update_issue(owner, repo, issue_id, labels=labels)
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
"""Close an issue (move to Terminé)."""
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()
return await self.update_issue(owner, repo, issue_id, state="closed")
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
"""Reopen an issue."""
self._invalidate_issue_cache(owner, repo)
return await self.update_issue(owner, repo, issue_id, state="open")
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:
resp = await client.patch(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
headers=self._headers,
json={"state": "open"},
)
resp.raise_for_status()
return resp.json()
@@ -165,7 +183,6 @@ class GiteaClient:
# ── labels ──
async def get_labels(self, owner: str, repo: str) -> list[dict]:
"""List repo labels."""
cache_key = f"labels:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
@@ -186,7 +203,6 @@ class GiteaClient:
async def get_milestones(
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
"""List repo milestones."""
cache_key = f"milestones:{owner}:{repo}:{state}"
cached = self._cached(cache_key)
if cached:
@@ -203,6 +219,73 @@ class GiteaClient:
self._set_cache(cache_key, 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 ──
gitea = GiteaClient()
+15
View File
@@ -18,12 +18,27 @@
</a>
</div>
<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>
</div>
</header>
<main class="main-content">
{% block content %}{% endblock %}
</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">&times;</button>
</div>
<div id="card-modal-content" class="modal-body"></div>
</div>
</div>
<script src="/static/js/app.js" defer></script>
</body>
</html>
+283 -21
View File
@@ -6,7 +6,10 @@
repo: '{{ repo }}',
milestone: '{{ current_milestone }}',
label: '{{ current_label }}',
assignee: '{{ current_assignee }}'
assignee: '{{ current_assignee }}',
showNewIssue: false,
showDetail: false,
detailIssueId: 0
}">
<!-- Header -->
@@ -19,29 +22,84 @@
<strong>{{ repo }}</strong>
</div>
<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="{{ 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>
<!-- 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 -->
<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>
{% for m in milestones %}
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
{% endfor %}
</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>
{% for lbl in labels %}
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
{% endfor %}
</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>
<!-- Columns -->
<div class="board-columns"
<div class="board-columns" id="board-columns"
hx-trigger="cardMoved from:body"
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
hx-target=".board-columns"
@@ -62,15 +120,16 @@
<div class="card"
data-issue-id="{{ issue.number }}"
data-column="{{ col }}"
draggable="true">
draggable="true"
ondblclick="openCardDetail('{{ owner }}','{{ repo }}',{{ issue.number }})">
<div class="card-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>
{% endfor %}
</div>
<div class="card-title">
<a href="{{ issue.html_url }}" target="_blank">#{{ issue.number }}</a>
{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}
<a href="{{ issue.html_url }}" target="_blank" onclick="event.stopPropagation()">#{{ issue.number }}</a>
<span class="card-title-text">{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}</span>
</div>
<div class="card-meta">
{% if issue.assignee %}
@@ -97,13 +156,35 @@
</div>
{% endfor %}
</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>
<script>
// ponytail: initialize SortableJS on each column
document.addEventListener('DOMContentLoaded', () => {
// Initialize SortableJS
document.addEventListener('DOMContentLoaded', initSortable);
document.addEventListener('htmx:afterSwap', initSortable);
function initSortable() {
document.querySelectorAll('.sortable').forEach(el => {
new Sortable(el, {
if (el._sortable) return;
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'card-ghost',
@@ -114,15 +195,13 @@
const owner = evt.to.dataset.owner;
const repo = evt.to.dataset.repo;
// Optimistic: move card in DOM is already done
// Persist to backend
const csrfToken = getCSRFToken();
try {
await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`, {
method: 'POST'
});
// Update column counts
const resp = await fetch(
`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`,
{ method: 'POST', headers: { 'X-CSRF-Token': csrfToken } }
);
if (!resp.ok) throw new Error('Move failed');
document.querySelectorAll('.board-column').forEach(col => {
const count = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count');
@@ -130,12 +209,195 @@
});
} catch (e) {
console.error('Move failed:', e);
// ponytail: reload on error
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>
{% endblock %}
+2
View File
@@ -9,3 +9,5 @@ aiosqlite==0.20.*
loguru==0.7.*
python-dotenv==1.0.*
packaging>=24.0
itsdangerous==2.2.*
slowapi==0.1.*
+85
View File
@@ -159,10 +159,95 @@ body { min-height: 100vh; }
.btn-primary:hover { opacity: 0.9; }
.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 ── */
@media (max-width: 768px) {
.board-columns { flex-direction: column; }
.board-column { max-width: 100%; min-height: 200px; }
.dashboard-header { flex-direction: column; align-items: flex-start; }
.search-input { width: 100%; }
.modal-container { width: 95%; max-height: 90vh; }
}