4 Commits
Author SHA1 Message Date
Spark 2d89db8213 chore(release): Kanbante 0.4.0
CI / test (push) Successful in 1m40s
Version, CHANGELOG (palier 2 + refonte) et README (fonctionnalités,
KANBANTE_WEBHOOK_SECRET / KANBANTE_SYNC_LABELS, routes webhook/stream/
milestone, procédure d'activation du webhook dans Gitea).
2026-10-11 10:23:50 -04:00
Spark 45283f0f8a test(palier-2): 13 tests — webhooks, SSE, étiquettes, milestones (65 verts)
FakeGitea: labels (repo_labels/label_ops), milestones (Sprint 1 sur
issues 1 et 3), invalidate_cache compteur. Le flux SSE est testé au
niveau du générateur: les clients de test HTTP ne ferment pas
proprement un flux infini (vérifié empiriquement); le bout-en-bout
HTTP a été validé en smoke test réel (curl + webhook).
2026-10-11 10:23:50 -04:00
Spark dbda3bcf5a refonte(ui): design system moderne et intuitif sur les 5 pages
- en-tête vitré sticky, navigation en pastilles avec état actif,
  logo en tuile dégradée, fil d'Ariane projet
- dashboard: hero + tuiles de synthèse (actifs, fermées/déplacements
  30 j, cycle moyen), barre de progression par projet, états vides
  guidés, raccourci clavier /
- board: sélecteur de milestones + bandeau de progression, colonnes
  à défilement indépendant, placeholders de colonnes vides, hint
  sur le panneau de notes, toast et drawer animés
- live.js: recharge douce conditionnelle (visible, pas de drawer,
  pas de saisie, pas de drag) sinon pastille d'actualisation
- accessibilité: anneau de focus visible, prefers-reduced-motion,
  responsive retravaillé; board.js: drapeau de drag partagé avec
  live.js + raccourci / vers le filtre
2026-10-11 10:23:50 -04:00
Spark 23e63c7f6e feat(palier-2): webhooks Gitea signés, flux SSE, étiquettes, milestones
- POST /webhooks/gitea: HMAC-SHA256 si KANBANTE_WEBHOOK_SECRET,
  applique fermetures/réouvertures externes de façon idempotente
  (l'écho de nos propres appels est un no-op, journal source=webhook),
  invalide le cache, diffuse sur le hub SSE
- GET /api/stream (SSE) + LiveHub; générateur testable séparément
- synchro d'étiquettes kanban:* à chaque déplacement (ensure/add/
  remove granulaires, étiquettes utilisateur intactes, flag
  KANBANTE_SYNC_LABELS, échec rapporté label_sync sans casser le move)
- board ?milestone=<titre|id>: filtre, échéance, progression;
  client: get_milestones, labels, invalidate_cache public
- dashboard: métriques 30 j du journal exposées au template
2026-10-11 10:23:50 -04:00
16 changed files with 1294 additions and 269 deletions
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## 0.4.0 — 2026-10-11
« Palier 2 » : temps réel et visible dans Gitea — plus une refonte
complète de l'interface.
### Ajouts
- **Webhooks Gitea + rafraîchissement en direct** : `POST /webhooks/gitea`
(signature HMAC-SHA256 vérifiée quand `KANBANTE_WEBHOOK_SECRET` est
défini) invalide le cache de lecture, applique les fermetures /
réouvertures faites dans Gitea (idempotent : l'écho de nos propres
appels ne double jamais le journal) et pousse un événement SSE sur
`GET /api/stream`. Le navigateur se recharge en douceur quand c'est
sûr (onglet visible, pas de panneau ouvert, pas de saisie en cours,
pas de drag) ; sinon une pastille « Nouvelle activité » propose
l'actualisation.
- **Synchro par étiquettes** : les colonnes intermédiaires sont
reflétées dans Gitea par des étiquettes `kanban:todo`,
`kanban:in_progress`, `kanban:review` (créées au besoin, ajout /
retrait granulaires — les étiquettes de l'utilisateur ne sont jamais
touchées ; backlog et done = aucune étiquette). Désactivable :
`KANBANTE_SYNC_LABELS=0`.
- **Boards par milestone** : `GET /board/{owner}/{repo}?milestone=<titre|id>`
filtre le board, avec bandeau d'échéance et progression
(issues fermées du milestone / total). Sélecteur de milestones
au-dessus du board.
### Interface (refonte complète)
- Nouveau design system : en-tête vitré sticky avec navigation en
pastilles (état actif), halos d'ambiance, cartes projets qui se
soulèvent au survol, barres de progression, coins plus doux,
anneau de focus visible partout, `prefers-reduced-motion` respecté.
- Dashboard : hero avec tuiles de synthèse (projets actifs, issues
fermées et déplacements sur 30 jours, temps de cycle moyen — depuis
le journal), états vides guidés pour chaque onglet, raccourci `/`
pour la recherche (aussi sur le filtre du board).
- Board : pastille « live » en cas d'activité externe, colonnes à
défilement indépendant, placeholders de colonnes vides, panneau de
notes explicité, drawer animé.
- Cohérence : même navigation sur les 5 pages, responsive retravaillé.
### Tests
- 13 tests ajoutés (65 au total) : hub SSE et générateur de flux,
webhooks (transition, idempotence, signature, JSON invalide),
synchro d'étiquettes, boards par milestone, marqueurs de la refonte.
## 0.3.0 — 2026-10-11
« Palier 1 » : voir ce qui se passe sur les boards.
+21
View File
@@ -9,6 +9,8 @@
- 🎯 **Drag & Drop** — glisse les issues entre colonnes ; en cas d'échec, la carte est replacée
- 🔗 **Gitea Sync bidirectionnelle** — « Terminé » ferme l'issue dans Gitea, la ressortir la rouvre ; l'état local est réconcilié à chaque chargement
- ➕ **Création d'issues** depuis le board, filtres texte + étiquettes, cartes liées à Gitea
- ⚡ **Temps réel** — un webhook Gitea invalide le cache et pousse un événement SSE : le board ouvert se met à jour tout seul (en douceur, sans interrompre une saisie ni un drag)
- 🏷️ **Visible dans Gitea** — les colonnes intermédiaires sont reflétées par des étiquettes `kanban:*` sur les issues, et un board peut se filtrer **par milestone** (échéance + progression façon sprint)
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
@@ -97,6 +99,8 @@ sudo systemctl enable --now docker
| `KANBANTE_SYNC_GITEA` | `1` | `0` = les déplacements ne ferment/rouvrent plus les issues Gitea |
| `KANBANTE_WIP_LIMITS` | *(vide)* | Limites souples par colonne, ex. `in_progress:5,review:3` (affichage `n/limite`, rouge en dépassement, jamais bloquant) |
| `KANBANTE_AGING_DAYS` | `7` | Jours dans la même colonne avant le badge « ⏳ » sur une carte (rouge au double) |
| `KANBANTE_WEBHOOK_SECRET` | *(vide)* | Secret partagé du webhook Gitea ; si défini, la signature HMAC `X-Gitea-Signature` est exigée |
| `KANBANTE_SYNC_LABELS` | `1` | `0` = ne plus refléter les colonnes intermédiaires en étiquettes `kanban:*` dans Gitea |
## Sécurité
@@ -146,6 +150,23 @@ kanbante/
| POST | `/api/issues/{owner}/{repo}/{n}/comments` | Commenter l'issue dans Gitea |
| GET | `/api/events` | Derniers événements du journal (`?repo=`, `?limit=`) |
| GET | `/api/metrics` | Débit + temps de cycle moyen (`?repo=`, `?window=`) |
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
## Activer le temps réel (webhook Gitea)
Dans chaque dépôt (ou au niveau d'une organisation) : **Réglages →
Webhooks → Ajouter un webhook → Gitea**, avec :
- **URL cible** : `https://<votre-hôte-kanbante>/webhooks/gitea`
- **Type de contenu** : `application/json`
- **Secret** : la même valeur que `KANBANTE_WEBHOOK_SECRET`
- **Déclencheur** : « Événements d'issue » (et « Commentaire d'issue »
si vous voulez aussi le rafraîchissement sur commentaires externes)
Dès lors, fermer ou rouvrir une issue dans Gitea met à jour les boards
Kanbante ouverts, sans rechargement manuel.
| POST | `/api/notes` | Sauvegarder des notes |
| GET | `/api/stats` | Statistiques JSON |
| GET | `/api/projects` | Projets JSON (actifs / inactifs / archivés) |
+10 -1
View File
@@ -23,6 +23,11 @@ Environment variables:
warns, moves are never blocked.
KANBANTE_AGING_DAYS Days in the same column before a card is
flagged as aging (default 7; "stale" at 2x).
KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook
(POST /webhooks/gitea). When set, the
X-Gitea-Signature HMAC is enforced.
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
columns to Gitea labels named kanban:<col>.
"""
import os
@@ -31,7 +36,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.3.0"
APP_VERSION = "0.4.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -81,6 +86,8 @@ class Settings:
sync_gitea: bool = True
wip_limits: dict | None = None
aging_days: int = 7
webhook_secret: str = ""
sync_labels: bool = True
@property
def auth_enabled(self) -> bool:
@@ -104,4 +111,6 @@ def get_settings() -> Settings:
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
)
+63
View File
@@ -120,6 +120,10 @@ class GiteaClient:
def _cache_clear(self) -> None:
self._cache.clear()
def invalidate_cache(self) -> None:
"""Drop every cached list response (called on webhook events)."""
self._cache.clear()
# ── API ────────────────────────────────────────────────
async def ping(self) -> bool:
@@ -221,6 +225,65 @@ class GiteaClient:
r.raise_for_status()
return r.json()
# ── Labels (kanban:* column mirroring) ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
return await self._get_all_pages(f"/repos/{owner}/{repo}/labels")
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
r = await self.client.post(
f"/repos/{owner}/{repo}/labels",
json={"name": name, "color": color.lstrip("#")},
)
r.raise_for_status()
return r.json()
async def ensure_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
"""Return the repo label with this name, creating it if needed."""
key = f"{owner}/{repo}"
cache = self.__dict__.setdefault("_label_cache", {})
if key not in cache:
cache[key] = {l["name"]: l for l in await self.get_labels(owner, repo)}
if name not in cache[key]:
cache[key][name] = await self.create_label(owner, repo, name, color)
return cache[key][name]
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [label_id]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
# ── Milestones ─────────────────────────────────────────
async def get_milestones(
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
key = f"milestones:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
milestones = await self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
)
self._cache_put(key, milestones)
return milestones
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
) -> dict:
+229 -6
View File
@@ -16,6 +16,9 @@ FastAPI backend serving HTML + JSON API.
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import secrets
from contextlib import asynccontextmanager
@@ -24,7 +27,7 @@ from pathlib import Path
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
from starlette.middleware.base import BaseHTTPMiddleware
@@ -119,7 +122,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
"""
async def dispatch(self, request: Request, call_next):
if request.url.path == "/api/health":
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
if request.url.path in ("/api/health", "/webhooks/gitea"):
return await call_next(request)
settings = get_settings()
if not settings.auth_enabled:
@@ -158,6 +163,37 @@ class AuthMiddleware(BaseHTTPMiddleware):
app.add_middleware(AuthMiddleware)
# ── Live updates (SSE hub) ─────────────────────────────────
class LiveHub:
"""Fan-out of Gitea webhook events to connected browsers (SSE)."""
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
async def broadcast(self, payload: dict) -> None:
for queue in list(self._subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass # slow client: it will resync on its next load
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
hub = LiveHub()
# ── Templates ──────────────────────────────────────────────
_tpl_env = Environment(
@@ -311,12 +347,13 @@ async def dashboard(request: Request):
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
active_window_days=get_settings().active_window_days,
metrics=get_metrics(window_days=30),
)
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str):
"""Kanban board for a specific repo."""
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
"""Kanban board for a specific repo (optionally one milestone)."""
full_name = f"{owner}/{repo}"
all_items = await gitea.get_repo_issues(owner, repo, state="all")
issues = [i for i in all_items if _is_issue(i)]
@@ -329,8 +366,47 @@ async def board(request: Request, owner: str, repo: str):
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
# Milestone view: filter the board to one milestone (sprint-style),
# with its deadline and completion progress.
milestones: list[dict] = []
selected_milestone = None
milestone_progress = None
display_issues = open_issues
try:
milestones = await gitea.get_milestones(owner, repo)
except Exception as exc: # milestones are decoration, never fatal
logger.debug("milestones failed for %s: %s", full_name, exc)
milestones = []
if milestone:
def _in_milestone(issue: dict) -> bool:
ms = issue.get("milestone") or {}
return bool(ms) and (
str(ms.get("id")) == milestone or ms.get("title") == milestone
)
display_issues = [i for i in open_issues if _in_milestone(i)]
selected_milestone = next(
(
m for m in milestones
if str(m.get("id")) == milestone or m.get("title") == milestone
),
None,
)
if selected_milestone is None and display_issues:
selected_milestone = display_issues[0].get("milestone")
scoped = [i for i in issues if _in_milestone(i)]
total = len(scoped)
closed_count = sum(1 for i in scoped if i.get("state") == "closed")
milestone_progress = {
"total": total,
"closed": closed_count,
"pct": round(100 * closed_count / total) if total else 0,
"due_on": (selected_milestone or {}).get("due_on"),
"title": (selected_milestone or {}).get("title", milestone),
}
board_state = get_board_state(full_name)
columns = _merge_issues_with_board(open_issues, board_state)
columns = _merge_issues_with_board(display_issues, board_state)
labels: dict[str, dict] = {}
for issue in open_issues:
@@ -356,9 +432,13 @@ async def board(request: Request, owner: str, repo: str):
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(full_name) or "",
repo_info=repo_info,
ages=_card_ages(full_name, open_issues, board_state),
ages=_card_ages(full_name, display_issues, board_state),
wip_limits=settings.wip_limits or {},
aging_days=settings.aging_days,
milestones=milestones,
selected_milestone=selected_milestone,
milestone_progress=milestone_progress,
milestone_param=milestone,
)
@@ -429,6 +509,117 @@ async def focus(request: Request):
)
# ── Webhooks + live stream ─────────────────────────────────
def _apply_external_transition(
full_name: str, number: int, target_column: str, event_type: str
) -> str:
"""Apply a close/reopen that happened in Gitea itself.
Idempotent by construction: when Kanbante's own API made the
change, the local row is already in the target state and this is
a no-op — so our own webhook echo never double-logs.
"""
board = get_board_state(full_name)
current = None
for column, cards in board.items():
if any(c["issue_number"] == number for c in cards):
current = column
break
if current is None or current == target_column:
return "noop"
if event_type == "reopened" and current != "done":
return "noop" # locally open already: nothing truthful to apply
move_issue(full_name, number, target_column)
if event_type == "closed":
log_event(full_name, number, "closed", to_column="done", source="webhook")
else:
log_event(
full_name, number, "reopened",
from_column="done", to_column=target_column, source="webhook",
)
return event_type
@app.post("/webhooks/gitea")
async def gitea_webhook(request: Request):
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
invalidate the read cache and push a live event to browsers."""
settings = get_settings()
raw = await request.body()
if settings.webhook_secret:
signature = request.headers.get("x-gitea-signature", "")
expected = hmac.new(
settings.webhook_secret.encode(), raw, hashlib.sha256
).hexdigest()
if not signature or not hmac.compare_digest(signature, expected):
return JSONResponse(
{"ok": False, "error": "Invalid webhook signature"},
status_code=401,
)
try:
payload = json.loads(raw or b"{}")
except ValueError:
return JSONResponse(
{"ok": False, "error": "Invalid JSON payload"}, status_code=400
)
event = request.headers.get("x-gitea-event", "")
action = payload.get("action", "")
full_name = (payload.get("repository") or {}).get("full_name", "")
issue = payload.get("issue") or {}
number = issue.get("number")
handled = "ignored"
if event == "issues" and full_name and number:
if action == "closed":
handled = _apply_external_transition(full_name, number, "done", "closed")
elif action == "reopened":
handled = _apply_external_transition(
full_name, number, "backlog", "reopened"
)
gitea.invalidate_cache()
await hub.broadcast(
{"repo": full_name, "event": event, "action": action, "issue": number}
)
return {"ok": True, "handled": handled}
async def sse_event_stream(request: Request):
"""Yield SSE frames for one subscriber until it disconnects.
Split out of the route so it can be unit-tested directly:
test clients cannot reliably tear down an infinite HTTP stream.
"""
queue = hub.subscribe()
try:
yield "retry: 5000\n\n"
while True:
try:
payload = await asyncio.wait_for(queue.get(), timeout=25)
except asyncio.TimeoutError:
if await request.is_disconnected():
break
yield ": heartbeat\n\n"
continue
if await request.is_disconnected():
break
yield f"event: gitea\ndata: {json.dumps(payload)}\n\n"
finally:
hub.unsubscribe(queue)
@app.get("/api/stream")
async def live_stream(request: Request):
"""Server-Sent Events: browsers reload softly on Gitea activity."""
return StreamingResponse(
sse_event_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/activity", response_class=HTMLResponse)
async def activity(request: Request):
"""Activity feed: the latest entries of the event journal."""
@@ -517,6 +708,38 @@ async def api_move(
exc,
)
result["gitea_sync"] = "failed"
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
# the workflow state is visible in Gitea itself, not just here.
result["label_sync"] = "skipped"
if settings.sync_labels and from_column != to_column:
try:
desired = (
f"kanban:{to_column}"
if to_column in ("todo", "in_progress", "review")
else None
)
issue = await gitea.get_issue(owner, repo, issue_number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await gitea.remove_issue_label(
owner, repo, issue_number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await gitea.ensure_label(
owner, repo, desired, COLUMN_COLORS[to_column]
)
await gitea.add_issue_label(owner, repo, issue_number, label["id"])
result["label_sync"] = "ok"
except Exception as exc:
logger.warning(
"Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc
)
result["label_sync"] = "failed"
return result
+10 -4
View File
@@ -8,10 +8,15 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 📰 Activité</h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
</nav>
</header>
@@ -48,5 +53,6 @@
<footer>
Kanbante v{{ version }} · journal d'événements
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+40 -11
View File
@@ -13,24 +13,48 @@
<script src="/static/js/board.js" defer></script>
</head>
<body>
<header>
<h1>
<a href="/">📋 Kanbante</a>
<span class="sep">/</span>
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
</h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<a href="/activity" class="btn-sm">📰 Activité</a>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</nav>
</header>
{% if milestones %}
<div class="milestone-bar">
<span class="milestone-label">🎯 Milestone</span>
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
{% for m in milestones %}
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
class="milestone-chip{% if milestone_param == m.title or milestone_param == m.id|string %} active{% endif %}">{{ m.title }}</a>
{% endfor %}
</div>
{% endif %}
{% if milestone_progress %}
<div class="milestone-banner">
<div class="milestone-info">
<strong>🎯 {{ milestone_progress.title }}</strong>
{% if milestone_progress.due_on %}
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
</div>
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
</div>
{% endif %}
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
<div class="board-toolbar">
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes...">
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
<select id="label-filter">
<option value="">Toutes les étiquettes</option>
{% for label in labels %}
@@ -56,6 +80,9 @@
{% if limit %}data-limit="{{ limit }}" title="Limite WIP : {{ limit }}"{% endif %}>{% if limit %}{{ columns[col]|length }}/{{ limit }}{% else %}{{ columns[col]|length }}{% endif %}</span>
</div>
<div class="column-cards" data-column="{{ col }}">
{% if not columns[col] %}
<p class="column-empty">Glissez une carte ici</p>
{% endif %}
{% for issue in columns[col] %}
<div class="kanban-card"
data-issue="{{ issue.number }}"
@@ -99,6 +126,7 @@
<!-- Notes panel -->
<aside class="notes-panel">
<h3>📝 Notes du projet</h3>
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea.</p>
<form hx-post="/api/notes" hx-swap="none"
hx-on::after-request="if(event.detail.successful) showNotesSaved()">
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
@@ -138,6 +166,7 @@
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
<script src="/static/js/live.js" defer></script>
<script>
function showNotesSaved() {
const toast = document.createElement('div');
+51 -9
View File
@@ -9,16 +9,30 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<a href="/activity" class="btn-sm">📰 Activité</a>
<span class="badge">{{ active_repos|length }} actifs</span>
<span class="badge secondary">{{ archived_repos|length }} archivés</span>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
</nav>
</header>
<section class="hero">
<div class="hero-text">
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
</div>
<div class="stat-tiles">
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
</div>
</section>
<main x-data="{ tab: 'active', search: '' }">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
@@ -37,7 +51,8 @@
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
by Jinja), never interpolated into a JS string literal. -->
<div class="search-bar">
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
{% macro project_card(repo, archived=False) %}
@@ -53,8 +68,17 @@
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
{% set total = stats[full].values()|sum %}
{% set done_n = stats[full].get('done', 0) %}
{% if total > 0 %}
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
</div>
{% endif %}
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues</span>
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','review','done'] %}
@@ -73,19 +97,37 @@
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</main>
<footer>
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
e.preventDefault();
document.getElementById('project-search')?.focus();
}
});
</script>
</body>
</html>
+9 -2
View File
@@ -7,8 +7,15 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
</nav>
</header>
<main class="error-main">
<div class="error-box">
+10 -3
View File
@@ -8,9 +8,15 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 🎯 Focus</h1>
<nav>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill active">🎯 Focus</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -58,5 +64,6 @@
<footer>
Kanbante v{{ version }} · vue Focus
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.3.0"
version = "0.4.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+498 -230
View File
@@ -1,136 +1,251 @@
/* ═══════════════════════════════════════════════
Kanbante — Dark theme design system
Kanbante — design system 0.4
Dark, calm, modern. One accent gradient, generous
radii, soft depth, visible focus, quiet motion.
═══════════════════════════════════════════════ */
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #242836;
--border: #2e3347;
--text: #e2e4e9;
--text2: #9498a4;
--bg: #0b0e14;
--bg-glow-1: rgba(124, 92, 252, 0.13);
--bg-glow-2: rgba(91, 138, 247, 0.10);
--surface: #141822;
--surface-2: #1b202e;
--surface-3: #232939;
--border: rgba(148, 152, 164, 0.16);
--border-strong: rgba(148, 152, 164, 0.30);
--text: #eceef3;
--text-2: #9aa0ae;
--text-3: #6d7280;
--accent: #7c5cfc;
--accent2: #5b8af7;
--accent-2: #5b8af7;
--green: #10b981;
--yellow: #f59e0b;
--red: #ef4444;
--radius: 10px;
--shadow: 0 2px 12px rgba(0,0,0,0.3);
--radius: 14px;
--radius-sm: 10px;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.38);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.25);
--ring: 0 0 0 3px rgba(124, 92, 252, 0.35);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background:
radial-gradient(900px 480px at 12% -8%, var(--bg-glow-1), transparent 60%),
radial-gradient(820px 460px at 88% -10%, var(--bg-glow-2), transparent 60%),
var(--bg);
background-attachment: fixed;
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
line-height: 1.45;
}
a { color: var(--accent2); text-decoration: none; }
::selection { background: rgba(124, 92, 252, 0.45); }
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Header */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
:focus-visible {
outline: none;
box-shadow: var(--ring);
border-radius: 6px;
}
button, input, select, textarea { font: inherit; }
/* ── App header ─────────────────────────────── */
.app-header, header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: rgba(16, 19, 28, 0.82);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.logo {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 1.18rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
}
.logo:hover { text-decoration: none; }
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: var(--shadow-sm);
font-size: 1rem;
}
header h1 { font-size: 1.25rem; font-weight: 600; }
header h1 .sep { color: var(--text2); margin: 0 8px; }
.gradient-text {
background: linear-gradient(135deg, var(--accent), var(--accent2));
background: linear-gradient(120deg, #a78bfa, #7dd3fc);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
nav { display: flex; gap: 10px; align-items: center; }
.crumb { color: var(--text-2); font-size: 1.02rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.crumb .sep, header h1 .sep { color: var(--text-3); margin: 0 4px; }
.crumb strong { color: var(--text); }
header h1 { font-size: 1.2rem; font-weight: 600; }
.nav-pills, nav { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.nav-pill {
padding: 7px 14px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 500;
color: var(--text-2);
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.nav-pill:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
.nav-pill.active {
color: #fff;
background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85));
border-color: rgba(255,255,255,0.12);
}
.badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: #c4b5fd;
}
.badge.secondary { background: var(--surface2); color: var(--text2); }
.badge.secondary { background: var(--surface-2); color: var(--text-2); }
.btn-sm {
padding: 6px 14px;
border-radius: 6px;
padding: 7px 14px;
border-radius: 999px;
font-size: 0.85rem;
background: var(--surface2);
font-weight: 500;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
transition: all 0.15s;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.btn-sm:hover { background: var(--border); text-decoration: none; }
.btn-sm:hover { background: var(--surface-3); border-color: var(--border-strong); text-decoration: none; }
.btn-sm:active { transform: scale(0.97); }
/* Tabs */
/* ── Hero (dashboard) ───────────────────────── */
.hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
flex-wrap: wrap;
padding: 30px 24px 6px;
}
.hero-text h2 { font-size: 1.5rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 6px; }
.hero-text p { color: var(--text-2); font-size: 0.95rem; max-width: 520px; }
.stat-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-tile {
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 18px;
min-width: 118px;
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-num { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
.stat-label { font-size: 0.72rem; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; }
/* ── Tabs + search (dashboard) ──────────────── */
.tabs {
display: flex;
gap: 4px;
padding: 16px 24px 0;
background: var(--surface);
gap: 6px;
padding: 18px 24px 0;
flex-wrap: wrap;
}
.tabs button {
padding: 10px 20px;
border: none;
background: transparent;
color: var(--text2);
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text-2);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
font-size: 0.88rem;
font-weight: 500;
transition: all 0.15s;
}
.tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
color: #fff;
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
border-color: transparent;
}
.tabs button:hover { color: var(--text); }
.tabs button.active:hover { color: #fff; }
/* Search */
.search-bar { padding: 12px 24px; background: var(--surface); }
.search-bar { padding: 14px 24px 2px; display: flex; align-items: center; gap: 10px; }
.search-bar input {
width: 100%;
max-width: 400px;
padding: 10px 16px;
border-radius: 8px;
max-width: 430px;
padding: 11px 16px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
font-size: 0.92rem;
outline: none;
transition: border-color 0.15s;
transition: border-color 0.15s, box-shadow 0.15s;
}
.search-bar input:focus { border-color: var(--accent); box-shadow: var(--ring); }
.kbd {
font-size: 0.72rem;
color: var(--text-3);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 6px;
padding: 2px 7px;
background: var(--surface);
}
.search-bar input:focus { border-color: var(--accent); }
/* Project Grid */
/* ── Project cards ──────────────────────────── */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
padding: 24px;
padding: 20px 24px 28px;
}
.project-card {
background: var(--surface);
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: default;
transition: transform 0.18s ease, border-color 0.18s, box-shadow 0.18s;
}
.project-card:hover {
border-color: var(--accent);
transform: translateY(-3px);
border-color: var(--border-strong);
box-shadow: var(--shadow);
}
.project-card.archived { opacity: 0.5; }
.project-card.archived:hover { opacity: 0.8; }
.project-card.archived { opacity: 0.55; }
.project-card.archived:hover { opacity: 0.85; }
.card-header {
display: flex;
justify-content: space-between;
@@ -138,42 +253,136 @@ nav { display: flex; gap: 10px; align-items: center; }
margin-bottom: 10px;
}
.org-tag {
font-size: 0.7rem;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
font-weight: 600;
letter-spacing: 0.6px;
color: #c4b5fd;
font-weight: 700;
}
.date { font-size: 0.75rem; color: var(--text2); }
.project-card h3 { font-size: 1.05rem; margin-bottom: 6px; }
.date { font-size: 0.75rem; color: var(--text-3); }
.project-card h3 { font-size: 1.08rem; margin-bottom: 6px; letter-spacing: -0.01em; }
.project-card h3 a { color: var(--text); }
.project-card h3 a:hover { color: var(--accent2); }
.project-card h3 a:hover { color: #a5c4fd; text-decoration: none; }
.desc {
font-size: 0.85rem;
color: var(--text2);
line-height: 1.4;
margin-bottom: 12px;
color: var(--text-2);
line-height: 1.45;
margin-bottom: 14px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.4em;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: var(--text2);
color: var(--text-2);
gap: 10px;
}
.mini-progress { display: flex; gap: 4px; }
.chip {
padding: 2px 8px;
border-radius: 12px;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 600;
font-weight: 700;
}
.progress {
height: 6px;
border-radius: 999px;
background: var(--surface-3);
overflow: hidden;
margin-bottom: 12px;
}
.progress > span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--green), #34d399);
transition: width 0.4s ease;
}
.progress-lg { height: 10px; flex: 1; margin-bottom: 0; }
.empty-state {
grid-column: 1 / -1;
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius);
padding: 34px 24px;
text-align: center;
color: var(--text-2);
font-size: 0.95rem;
background: rgba(255,255,255,0.015);
}
/* ── Kanban Board ────────────────────────────── */
/* ── Milestones (board) ─────────────────────── */
.milestone-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 14px 24px 0;
}
.milestone-label { font-size: 0.82rem; color: var(--text-2); font-weight: 600; margin-right: 2px; }
.milestone-chip {
padding: 6px 13px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-2);
font-size: 0.83rem;
font-weight: 500;
transition: all 0.15s;
}
.milestone-chip:hover { color: var(--text); text-decoration: none; border-color: var(--border-strong); }
.milestone-chip.active {
color: #fff;
background: linear-gradient(135deg, rgba(124,92,252,.9), rgba(91,138,247,.9));
border-color: transparent;
}
.milestone-banner {
display: flex;
align-items: center;
gap: 16px;
margin: 14px 24px 0;
padding: 14px 18px;
background: linear-gradient(120deg, rgba(124,92,252,.14), rgba(91,138,247,.10));
border: 1px solid rgba(124,92,252,.35);
border-radius: var(--radius);
flex-wrap: wrap;
}
.milestone-info { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; font-size: 0.92rem; }
.milestone-due, .milestone-count { color: var(--text-2); font-size: 0.85rem; }
.milestone-pct { font-weight: 700; font-size: 1.05rem; }
/* ── Board toolbar ──────────────────────────── */
.board-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 16px 24px 4px;
}
.board-toolbar input[type="text"],
.board-toolbar select {
padding: 10px 15px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 0.9rem;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.board-toolbar select { border-radius: var(--radius-sm); }
.board-toolbar input[type="text"]:focus,
.board-toolbar select:focus { border-color: var(--accent); box-shadow: var(--ring); }
.board-toolbar form { display: flex; gap: 8px; flex: 1; min-width: 260px; }
.board-toolbar form input { flex: 1; }
.btn-inline { margin-top: 0; padding: 10px 20px; border-radius: 999px; }
/* ── Kanban board ───────────────────────────── */
.board-container {
display: flex;
flex: 1;
@@ -181,299 +390,358 @@ nav { display: flex; gap: 10px; align-items: center; }
}
.kanban-board {
display: flex;
gap: 0;
flex: 1;
overflow-x: auto;
padding: 20px;
padding: 16px 24px 22px;
gap: 14px;
align-items: flex-start;
scroll-snap-type: x proximity;
}
.kanban-column {
min-width: 280px;
max-width: 340px;
min-width: 282px;
max-width: 342px;
flex: 1;
display: flex;
flex-direction: column;
background: var(--surface);
background: rgba(20, 24, 34, 0.72);
border-radius: var(--radius);
margin-right: 12px;
border: 1px solid var(--border);
scroll-snap-align: start;
max-height: calc(100vh - 210px);
}
.column-header {
padding: 14px 16px;
padding: 13px 16px 11px;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: var(--radius) var(--radius) 0 0;
}
.column-title { font-weight: 600; font-size: 0.9rem; }
.column-title { font-weight: 650; font-size: 0.9rem; letter-spacing: 0.01em; }
.column-count {
background: var(--surface2);
background: var(--surface-3);
padding: 2px 10px;
border-radius: 12px;
font-size: 0.75rem;
color: var(--text2);
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
color: var(--text-2);
}
.column-count.over {
background: color-mix(in srgb, var(--red) 22%, transparent);
color: #fca5a5;
font-weight: 700;
}
.column-cards {
flex: 1;
padding: 8px;
min-height: 120px;
padding: 4px 10px 12px;
min-height: 90px;
overflow-y: auto;
}
.column-empty {
color: var(--text-3);
font-size: 0.8rem;
text-align: center;
padding: 16px 8px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-sm);
margin-top: 4px;
}
/* Cards */
.kanban-card {
background: var(--surface2);
background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
border-radius: var(--radius-sm);
padding: 12px 13px;
margin-bottom: 9px;
cursor: grab;
transition: box-shadow 0.15s, transform 0.15s;
transition: box-shadow 0.16s, transform 0.16s, border-color 0.16s;
}
.kanban-card:hover {
box-shadow: var(--shadow-sm);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.kanban-card:hover { box-shadow: var(--shadow); }
.kanban-card:active { cursor: grabbing; }
.kanban-card.dragging { opacity: 0.5; transform: rotate(2deg); }
.kanban-card.dragging { opacity: 0.55; transform: rotate(2deg) scale(1.02); box-shadow: var(--shadow); }
.card-id {
font-size: 0.7rem;
color: var(--accent);
font-weight: 600;
color: #c4b5fd;
font-weight: 700;
margin-bottom: 4px;
display: flex;
gap: 6px;
align-items: center;
}
.card-title {
font-size: 0.85rem;
line-height: 1.4;
margin-bottom: 6px;
}
.card-id a { color: var(--text-3); }
.card-id a:hover { color: var(--accent-2); text-decoration: none; }
.card-title { font-size: 0.87rem; line-height: 1.42; margin-bottom: 7px; }
.card-labels { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.label-tag {
padding: 1px 8px;
border-radius: 10px;
font-size: 0.65rem;
font-weight: 600;
border-radius: 999px;
font-size: 0.64rem;
font-weight: 700;
}
.card-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
font-size: 0.7rem;
color: var(--text2);
color: var(--text-3);
}
.avatar { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border-strong); }
.age-badge {
background: color-mix(in srgb, var(--yellow) 18%, transparent);
color: #fcd34d;
padding: 1px 8px;
border-radius: 999px;
font-weight: 700;
}
.age-badge.stale {
background: color-mix(in srgb, var(--red) 20%, transparent);
color: #fca5a5;
}
.avatar { width: 20px; height: 20px; border-radius: 50%; }
/* Notes panel */
.notes-panel {
width: 280px;
background: var(--surface);
width: 292px;
background: rgba(20, 24, 34, 0.72);
border-left: 1px solid var(--border);
padding: 20px;
display: flex;
flex-direction: column;
}
.notes-panel h3 { font-size: 0.9rem; margin-bottom: 12px; }
.notes-panel h3 { font-size: 0.92rem; margin-bottom: 6px; }
.notes-hint { font-size: 0.78rem; color: var(--text-3); margin-bottom: 12px; line-height: 1.45; }
.notes-panel form { display: flex; flex-direction: column; flex: 1; }
.notes-panel textarea {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
color: var(--text);
padding: 12px;
font-size: 0.85rem;
font-family: inherit;
resize: none;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.notes-panel textarea:focus { border-color: var(--accent); }
.notes-panel textarea:focus { border-color: var(--accent); box-shadow: var(--ring); }
.btn-save {
margin-top: 10px;
padding: 10px;
background: var(--accent);
color: white;
padding: 10px 18px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
border: none;
border-radius: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: opacity 0.15s;
font-size: 0.87rem;
font-weight: 600;
transition: filter 0.15s, transform 0.1s;
box-shadow: var(--shadow-sm);
}
.btn-save:hover { opacity: 0.9; }
.btn-save:hover { filter: brightness(1.12); }
.btn-save:active { transform: scale(0.98); }
/* Footer */
footer {
padding: 12px 24px;
background: var(--surface);
padding: 14px 24px;
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text2);
font-size: 0.79rem;
color: var(--text-3);
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: auto;
}
/* Toast notification */
/* Toast + live pill */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: 8px;
background: var(--green);
color: white;
font-size: 0.85rem;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, #059669, var(--green));
color: #fff;
font-size: 0.86rem;
font-weight: 500;
box-shadow: var(--shadow);
z-index: 1000;
animation: slideIn 0.3s ease;
animation: toast-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
@keyframes slideIn { from { transform: translateY(20px); opacity: 0; } }
.toast.error { background: linear-gradient(135deg, #b91c1c, var(--red)); }
@keyframes toast-in { from { transform: translateY(16px) scale(0.96); opacity: 0; } }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
#live-pill {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 11px 20px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.18);
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff;
font-size: 0.87rem;
font-weight: 600;
cursor: pointer;
box-shadow: var(--shadow);
animation: toast-in 0.3s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}
#live-pill:hover { filter: brightness(1.12); }
/* Scrollbars */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text2); }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-3); }
/* Toast error variant */
.toast.error { background: var(--red); }
/* Board toolbar (filters + create) */
.board-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
/* ── Focus view ─────────────────────────────── */
.focus-main { padding: 26px 24px; max-width: 1120px; width: 100%; margin: 0 auto; }
.focus-intro { color: var(--text-2); margin-bottom: 22px; font-size: 0.96rem; }
.focus-empty {
font-size: 1.02rem;
padding: 36px 24px;
text-align: center;
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius);
color: var(--text-2);
}
.board-toolbar input[type="text"],
.board-toolbar select {
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
outline: none;
}
.board-toolbar input[type="text"]:focus,
.board-toolbar select:focus { border-color: var(--accent); }
.board-toolbar form { display: flex; gap: 8px; flex: 1; min-width: 260px; }
.board-toolbar form input { flex: 1; }
.btn-inline { margin-top: 0; padding: 9px 18px; }
.card-id a { color: var(--text2); }
.card-id a:hover { color: var(--accent2); }
/* Focus view */
.focus-main { padding: 24px; max-width: 1100px; width: 100%; margin: 0 auto; }
.focus-intro { color: var(--text2); margin-bottom: 20px; }
.focus-empty { font-size: 1.05rem; padding: 32px 0; }
.focus-group { margin-bottom: 28px; }
.focus-group h2 { font-size: 1.05rem; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; }
.focus-group { margin-bottom: 30px; }
.focus-group h2 { font-size: 1.06rem; margin-bottom: 12px; display: flex; gap: 10px; align-items: center; }
.focus-group h2 a { color: var(--text); }
.focus-group h2 a:hover { color: #a5c4fd; text-decoration: none; }
.focus-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.focus-card { margin-bottom: 0; cursor: default; }
/* Error page */
/* ── Error page ─────────────────────────────── */
.error-main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 24px; }
.error-box {
background: var(--surface);
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
padding: 36px;
max-width: 560px;
text-align: center;
box-shadow: var(--shadow-sm);
}
.error-box h2 { margin-bottom: 12px; }
.error-box p { color: var(--text2); margin-bottom: 16px; line-height: 1.5; }
.error-box p { color: var(--text-2); margin-bottom: 16px; line-height: 1.55; }
/* Aging badges + WIP over-limit */
.age-badge {
background: color-mix(in srgb, var(--yellow) 18%, transparent);
color: var(--yellow);
padding: 1px 7px;
border-radius: 10px;
font-weight: 600;
}
.age-badge.stale {
background: color-mix(in srgb, var(--red) 18%, transparent);
color: var(--red);
}
.column-count.over {
background: color-mix(in srgb, var(--red) 20%, transparent);
color: var(--red);
font-weight: 700;
}
/* Card detail drawer */
/* ── Card detail drawer ─────────────────────── */
.detail-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
background: rgba(4, 6, 10, 0.6);
backdrop-filter: blur(3px);
z-index: 200;
animation: fade-in 0.18s ease;
}
@keyframes fade-in { from { opacity: 0; } }
.card-detail {
position: fixed; top: 0; right: 0;
width: min(480px, 94vw); height: 100vh;
width: min(500px, 94vw); height: 100dvh;
background: var(--surface);
border-left: 1px solid var(--border);
border-left: 1px solid var(--border-strong);
z-index: 201;
padding: 20px;
padding: 22px;
overflow-y: auto;
box-shadow: var(--shadow);
animation: drawer-in 0.24s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.detail-head { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
@keyframes drawer-in { from { transform: translateX(40px); opacity: 0; } }
.detail-head { display: flex; gap: 10px; align-items: center; margin-bottom: 12px; }
.detail-head .btn-sm { margin-left: auto; }
.card-detail h2 { font-size: 1.15rem; line-height: 1.35; margin-bottom: 8px; }
.detail-meta { color: var(--text2); font-size: 0.85rem; margin-bottom: 10px; }
.card-detail h2 { font-size: 1.18rem; line-height: 1.35; margin-bottom: 8px; letter-spacing: -0.01em; }
.detail-meta { color: var(--text-2); font-size: 0.85rem; margin-bottom: 10px; }
.detail-body {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
border-radius: var(--radius-sm);
padding: 15px;
font-size: 0.9rem;
line-height: 1.55;
line-height: 1.6;
margin: 12px 0;
word-break: break-word;
}
.detail-body p { margin-bottom: 10px; }
.detail-body pre { background: var(--surface2); padding: 10px; border-radius: 6px; overflow-x: auto; }
.detail-body ul, .detail-body ol { margin: 0 0 10px 20px; }
.detail-body pre { background: var(--surface-2); padding: 11px; border-radius: 8px; overflow-x: auto; margin-bottom: 10px; }
.detail-body code { font-size: 0.82rem; }
.detail-empty { color: var(--text2); font-style: italic; }
.detail-comments-title { font-size: 0.95rem; margin: 16px 0 10px; }
.detail-body a { word-break: break-all; }
.detail-empty { color: var(--text-3); font-style: italic; }
.detail-comments-title { font-size: 0.96rem; margin: 18px 0 10px; }
.comment {
background: var(--surface2);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 8px;
border-radius: var(--radius-sm);
padding: 11px 13px;
margin-bottom: 9px;
}
.comment-head { font-size: 0.75rem; color: var(--text2); margin-bottom: 6px; }
.comment-body { font-size: 0.88rem; line-height: 1.5; word-break: break-word; }
.comment-head { font-size: 0.74rem; color: var(--text-3); margin-bottom: 6px; }
.comment-body { font-size: 0.88rem; line-height: 1.55; word-break: break-word; }
.comment-body p { margin-bottom: 8px; }
#comment-form textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
color: var(--text);
padding: 10px;
padding: 11px;
font-family: inherit;
font-size: 0.88rem;
resize: vertical;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
#comment-form textarea:focus { border-color: var(--accent); box-shadow: var(--ring); }
.detail-foot { margin-top: 14px; font-size: 0.85rem; }
/* Activity feed */
/* ── Activity feed ──────────────────────────── */
.activity-list { display: flex; flex-direction: column; gap: 8px; }
.activity-item {
display: flex; gap: 12px; align-items: baseline;
background: var(--surface);
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
border-radius: var(--radius-sm);
padding: 11px 15px;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.activity-date { color: var(--text2); font-size: 0.78rem; margin-left: 8px; }
.activity-item:hover { border-color: var(--border-strong); }
.activity-date { color: var(--text-3); font-size: 0.77rem; margin-left: 8px; white-space: nowrap; }
.activity-icon { font-size: 1rem; line-height: 1; }
.activity-text { flex: 1; }
.card-date { color: var(--text-3); }
/* Responsive */
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
.hero { flex-direction: column; align-items: flex-start; }
.kanban-column { max-height: none; }
}
@media (max-width: 768px) {
.board-container { flex-direction: column; }
.notes-panel { width: 100%; max-height: 200px; border-left: none; border-top: 1px solid var(--border); }
.kanban-board { flex-direction: column; }
.kanban-column { max-width: 100%; min-width: 100%; margin-right: 0; margin-bottom: 12px; }
.app-header, header { padding: 10px 14px; }
.crumb { display: none; }
.hero, .tabs, .search-bar, .project-grid, .board-toolbar, .milestone-bar { padding-left: 14px; padding-right: 14px; }
.milestone-banner { margin-left: 14px; margin-right: 14px; }
.board-container { flex-direction: column; overflow: visible; }
.notes-panel { width: 100%; max-height: 240px; border-left: none; border-top: 1px solid var(--border); }
.kanban-board { flex-direction: column; overflow-x: visible; padding: 14px; }
.kanban-column { max-width: 100%; min-width: 100%; }
.project-grid { grid-template-columns: 1fr; }
.column-cards { overflow-y: visible; }
footer { padding: 12px 14px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+10
View File
@@ -29,8 +29,10 @@ document.addEventListener('DOMContentLoaded', () => {
group: 'kanban',
animation: 200,
ghostClass: 'dragging',
onStart: function () { window.__kanbanteDragging = true; },
dragClass: 'dragging',
onEnd: function (evt) {
window.__kanbanteDragging = false;
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
const card = evt.item;
const formData = new FormData();
@@ -128,6 +130,14 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Keyboard: "/" jumps to the filter box.
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
e.preventDefault();
document.getElementById('card-filter')?.focus();
}
});
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
let downX = 0, downY = 0;
+44
View File
@@ -0,0 +1,44 @@
// Kanbante — live updates via Server-Sent Events.
// When Gitea reports activity (webhook -> /api/stream), reload the
// page softly if it is safe (visible tab, no drawer open, no text
// being typed, no drag in progress); otherwise show a pill the
// user can click when ready.
(function () {
if (!window.EventSource) return;
var pill = null;
var timer = null;
function showPill() {
if (pill) return;
pill = document.createElement('button');
pill.id = 'live-pill';
pill.type = 'button';
pill.textContent = '🔄 Nouvelle activité Gitea — actualiser';
pill.addEventListener('click', function () { window.location.reload(); });
document.body.appendChild(pill);
}
function safeToReload() {
if (document.visibilityState !== 'visible') return false;
if (window.__kanbanteDragging) return false;
var drawer = document.getElementById('card-detail');
if (drawer && !drawer.hidden) return false;
var active = document.activeElement;
if (active && (active.tagName === 'TEXTAREA' ||
(active.tagName === 'INPUT' && active.type === 'text'))) return false;
return true;
}
function scheduleReload() {
if (timer) return;
timer = setTimeout(function () {
timer = null;
if (safeToReload()) window.location.reload();
else showPill();
}, 1200);
}
var source = new EventSource('/api/stream');
source.addEventListener('gitea', scheduleReload);
})();
+51 -2
View File
@@ -47,10 +47,10 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
"title": title,
"body": f"Corps de l'issue {number} en **markdown**.",
"state": state,
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": None,
"assignees": [],
"milestone": None,
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
"due_date": None,
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-02T09:00:00Z",
@@ -79,6 +79,10 @@ class FakeGitea:
_issue(5, "Tâche focus"),
],
}
self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}}
self.label_ops: list[tuple] = []
self._label_seq = 100
self.cache_invalidations = 0
self.state_changes: list[tuple] = []
self.created: list[tuple] = []
self.comments: dict[tuple, list[dict]] = {
@@ -150,6 +154,51 @@ class FakeGitea:
self.posted_comments.append((owner, repo, number, body))
return comment
def invalidate_cache(self):
self.cache_invalidations += 1
async def get_labels(self, owner, repo):
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
async def create_label(self, owner, repo, name, color):
self._label_seq += 1
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
self.label_ops.append(("create", f"{owner}/{repo}", name))
return label
async def ensure_label(self, owner, repo, name, color):
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
if name in existing:
return existing[name]
return await self.create_label(owner, repo, name, color)
async def add_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
label = next(
l for l in self.repo_labels[f"{owner}/{repo}"].values()
if l["id"] == label_id
)
if label not in issue["labels"]:
issue["labels"].append(label)
self.label_ops.append(("add", number, label["name"]))
async def remove_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
removed = [l for l in issue["labels"] if l["id"] == label_id]
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
for label in removed:
self.label_ops.append(("remove", number, label["name"]))
async def get_milestones(self, owner, repo, state="open"):
return [
{
"id": 1, "title": "Sprint 1",
"due_on": "2026-10-20T00:00:00Z",
"open_issues": 1, "closed_issues": 1,
}
]
async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1
issue = _issue(self._next_number, title)
+202
View File
@@ -0,0 +1,202 @@
"""Palier 2 (0.4.0): webhooks + SSE live, label sync, milestone boards."""
import asyncio
import hashlib
import hmac
import json
from app import models
from app.main import hub
from tests.conftest import csrf_from
def _move(client, number, to_column, from_column):
token = csrf_from(client)
return client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo", "issue_number": number,
"to_column": to_column, "from_column": from_column,
"csrf_token": token,
},
)
def _webhook(client, payload, event="issues", secret=None, raw=None):
body = raw if raw is not None else json.dumps(payload).encode()
headers = {"X-Gitea-Event": event, "Content-Type": "application/json"}
if secret is not None:
headers["X-Gitea-Signature"] = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return client.post("/webhooks/gitea", content=body, headers=headers)
# ── Hub SSE ────────────────────────────────────────────────
def test_hub_broadcast_reaches_subscribers():
async def scenario():
queue = hub.subscribe()
try:
await hub.broadcast({"repo": "a/b", "action": "closed"})
return await asyncio.wait_for(queue.get(), timeout=1)
finally:
hub.unsubscribe(queue)
payload = asyncio.run(scenario())
assert payload["action"] == "closed"
assert hub.subscriber_count == 0
def test_sse_event_stream_frames_and_cleanup():
# The generator is unit-tested directly: HTTP test clients cannot
# reliably tear down an infinite SSE stream (verified empirically).
from app.main import sse_event_stream
class FakeRequest:
async def is_disconnected(self):
return False
async def scenario():
gen = sse_event_stream(FakeRequest())
first = await gen.__anext__()
await hub.broadcast({"repo": "a/b", "action": "closed"})
second = await asyncio.wait_for(gen.__anext__(), timeout=5)
await gen.aclose()
return first, second
first, second = asyncio.run(scenario())
assert first == "retry: 5000\n\n"
assert second.startswith("event: gitea\ndata: ")
assert '"action": "closed"' in second
assert hub.subscriber_count == 0 # finally unsubscribed
# ── Webhooks ───────────────────────────────────────────────
def test_webhook_closed_applies_transition_and_journals(client, fake_gitea):
models.move_issue("Projets/demo", 1, "todo")
payload = {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}
r = _webhook(client, payload)
assert r.status_code == 200
assert r.json()["handled"] == "closed"
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["done"]] == [1]
events = models.get_events("Projets/demo")
assert events[0]["event_type"] == "closed"
assert events[0]["source"] == "webhook"
assert fake_gitea.cache_invalidations >= 1
# The echo of our own API moves arrives in the same state: no-op,
# no duplicate journal entry.
r = _webhook(client, payload)
assert r.json()["handled"] == "noop"
assert len(models.get_events("Projets/demo")) == 1
def test_webhook_reopened_moves_back_to_backlog(client):
models.move_issue("Projets/demo", 1, "done")
r = _webhook(client, {
"action": "reopened",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
})
assert r.json()["handled"] == "reopened"
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["backlog"]] == [1]
def test_webhook_signature_enforced_when_secret_set(client, monkeypatch):
monkeypatch.setenv("KANBANTE_WEBHOOK_SECRET", "s3cret")
payload = {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}
assert _webhook(client, payload).status_code == 401
assert _webhook(client, payload, secret="wrong").status_code == 401
assert _webhook(client, payload, secret="s3cret").status_code == 200
def test_webhook_invalid_json_is_400(client):
r = _webhook(client, None, raw=b"{not json")
assert r.status_code == 400
def test_webhook_ignores_unrelated_events(client):
r = _webhook(client, {
"action": "created",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}, event="issue_comment")
assert r.status_code == 200
assert r.json()["handled"] == "ignored"
# ── Synchro par étiquettes ─────────────────────────────────
def test_label_sync_follows_column_moves(client, fake_gitea):
r = _move(client, 1, "in_progress", "backlog")
assert r.json()["label_sync"] == "ok"
assert ("create", "Projets/demo", "kanban:in_progress") in fake_gitea.label_ops
assert ("add", 1, "kanban:in_progress") in fake_gitea.label_ops
r = _move(client, 1, "review", "in_progress")
assert r.json()["label_sync"] == "ok"
assert ("remove", 1, "kanban:in_progress") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
r = _move(client, 1, "done", "review")
assert r.json()["label_sync"] == "ok"
assert ("remove", 1, "kanban:review") in fake_gitea.label_ops
issue = fake_gitea.issues["Projets/demo"][0]
assert not any(l["name"].startswith("kanban:") for l in issue["labels"])
assert any(l["name"] == "bug" for l in issue["labels"]) # user's label kept
def test_label_sync_can_be_disabled(client, fake_gitea, monkeypatch):
monkeypatch.setenv("KANBANTE_SYNC_LABELS", "0")
r = _move(client, 1, "in_progress", "backlog")
assert r.json()["label_sync"] == "skipped"
assert fake_gitea.label_ops == []
# ── Boards par milestone ───────────────────────────────────
def test_board_filters_by_milestone_with_progress(client):
models.move_issue("Projets/demo", 3, "done")
r = client.get("/board/Projets/demo?milestone=Sprint%201")
assert r.status_code == 200
assert "Tâche un" in r.text # open, in Sprint 1
assert "Tâche focus" not in r.text # open, no milestone
assert "milestone-banner" in r.text
assert "1/2 issues terminées" in r.text
assert "50%" in r.text
assert "2026-10-20" in r.text # due_on from the milestone
def test_board_lists_milestone_chips(client):
r = client.get("/board/Projets/demo")
assert "milestone-chip" in r.text
assert "Sprint 1" in r.text
assert "milestone=Sprint" in r.text
def test_board_filter_by_milestone_id(client):
r = client.get("/board/Projets/demo?milestone=1")
assert "Tâche un" in r.text
assert "Tâche focus" not in r.text
# ── Refonte UI : marqueurs structurels ─────────────────────
def test_dashboard_uses_new_design_system(client):
r = client.get("/")
assert "app-header" in r.text
assert "nav-pill" in r.text
assert "stat-tile" in r.text
assert "live.js" in r.text