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
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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_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"
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 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)}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
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()
|
||||
|
||||
@@ -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">×</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
@@ -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,'&').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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,3 +9,5 @@ aiosqlite==0.20.*
|
||||
loguru==0.7.*
|
||||
python-dotenv==1.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-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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user