13 Commits
Author SHA1 Message Date
Spark 7d2faf027d chore(release): Kanbante 0.5.0
CI / test (push) Successful in 1m45s
Version, CHANGELOG (palier 3 points 7-9 + board) et README
(fonctionnalités, variables ntfy/briefing, nouvelles routes,
branchement d'un client MCP).
2026-10-11 11:00:53 -04:00
Spark b99e87ccca test(palier-3): 19 tests — recherche, mes issues, briefing, MCP, étiquettes (84 verts)
FakeGitea: get_user, search_issues (filtres q/état/assigné, PR
exclues, repository joint), delete_label, forget_label_cache;
issue 1 assignée à bruno pour /my.
2026-10-11 11:00:53 -04:00
Spark 6887d631ea feat(board): étiquettes gérables, notes repliable/redimensionnable/auto-save
- modale Étiquettes: liste, création (nom + couleur + palette),
  suppression via DELETE (CSRF par en-tête); panneau détail:
  bascules d'étiquettes par issue avec mise à jour immédiate des
  chips de la carte; _issue_payload expose les ids d'étiquettes
- API étiquettes dans main (routes repos + toggle issue)
- notes: bouton replier/déplier et largeur par glisser, persistés
  par projet (localStorage), sauvegarde automatique débouncée
  avec statut, formulaire repris par board.js (HTMX retiré du board)
- nouvelles pages search/my/briefing; navigation enrichie partout
  (Mes issues, Briefing, Recherche); CSS associé
2026-10-11 11:00:53 -04:00
Spark a788db7b1f feat(palier-3): recherche globale, mes issues, briefing ntfy, MCP lecture
- client: search_issues (/repos/issues/search, type=issues,
  assigned_by), delete_label + forget_label_cache; models: kv_store
- /search groupe par projet avec colonne Kanban; /my = issues
  ouvertes assignées à l'utilisateur du jeton (get_user)
- briefing: _briefing_data (focus, âgées, milestones <= 7 j,
  activité journal), page /briefing, /api/briefing, envoi ntfy
  manuel + planificateur lifespan à KANBANTE_BRIEFING_TIME
  (kv briefing_last_sent, pas de doublon); app/briefing.py =
  format texte + envoi ntfy (en-têtes ASCII)
- MCP: POST /mcp JSON-RPC (initialize, tools/list, tools/call,
  ping, notifications 202), 7 outils en lecture seule, GET /mcp
  de découverte; erreurs -32601/-32700, isError pour les outils
- focus refactoré en _collect_focus_groups réutilisé par le
  briefing et l'outil MCP get_focus
2026-10-11 11:00:53 -04:00
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
Spark 3cac4fe9c3 chore(release): Kanbante 0.3.0
CI / test (push) Successful in 1m39s
Version, CHANGELOG (palier 1) et README (fonctionnalités,
KANBANTE_WIP_LIMITS / KANBANTE_AGING_DAYS, nouvelles routes).
2026-10-11 10:05:51 -04:00
Spark f1dd921e63 test(palier-1): 16 tests — journal, métriques, détail, âge, WIP (52 verts)
FakeGitea étendu (get_issue, commentaires); le test de réconciliation
suit le nouveau retour de reconcile_board_state; l'assertion de
version suit APP_VERSION.
2026-10-11 10:05:51 -04:00
Spark 9bf24da04e feat(frontend): panneau détail de carte, badges d'âge, limites WIP
- drawer de détail: corps markdown rendu (marked + DOMPurify
  vendorés), commentaires, formulaire de commentaire vers Gitea;
  clic distingué du drag par la distance du pointeur
- badges ⏳ N j au-delà du seuil d'âge (rouge au double)
- en-têtes de colonnes: n/limite, classe .over en rouge, toast
  d'avertissement au dépôt dans une colonne saturée
- page activity.html, liens Activité dans dashboard et board
2026-10-11 10:05:51 -04:00
Spark a6d2ed9eae feat(palier-1): journal d'événements, détail d'issue, vieillissement, WIP
- board_events append-only + log_event/get_events/column_entry_times/
  get_metrics (débit, temps de cycle); journalisation des moves,
  créations, fermetures/rouvertures (UI et réconciliation Gitea),
  commentaires; un reclassement intra-colonne ne journalise pas
- client Gitea: get_issue, get_issue_comments (paginés), create_comment
- API: GET /api/issues/{o}/{r}/{n} (404 propre), POST .../comments,
  GET /api/events, GET /api/metrics, page /activity
- config: KANBANTE_WIP_LIMITS (colonne:limite) et KANBANTE_AGING_DAYS;
  le board calcule le temps en colonne (journal, replis updated_at
  puis création de l'issue)
- reconcile_board_state retourne aussi les numéros fermés/purgés
2026-10-11 10:05:51 -04:00
Spark 2060ca949d fix(ci): la collecte des tests échouait sous le binaire pytest
CI / test (push) Successful in 1m38s
from tests.conftest import ... levait ModuleNotFoundError en CI :
le binaire pytest n'ajoute pas le répertoire courant à sys.path,
contrairement à python -m pytest (utilisé en local, qui masquait le
problème). Correctifs : tests/__init__.py (package), pythonpath=[.]
dans la config pytest, et étape CI en python -m pytest.
Vérifié : 36/36 dans les trois modes d'invocation.
2026-10-11 09:41:15 -04:00
29 changed files with 3909 additions and 249 deletions
+1 -1
View File
@@ -17,4 +17,4 @@ jobs:
- name: Compile check
run: python -m py_compile app/*.py
- name: Tests
run: pytest -q
run: python -m pytest -q
+119
View File
@@ -1,5 +1,124 @@
# Changelog
## 0.5.0 — 2026-10-11
« Palier 3 » (points 7 à 9) + board enrichi.
### Ajouts
- **Recherche globale** (`/search`) : recherche d'issues dans tous les
projets via l'API Gitea, résultats groupés par projet avec la colonne
Kanban de chaque issue. **Mes issues** (`/my`) : les issues ouvertes
assignées à l'utilisateur du jeton, tous projets confondus.
- **Briefing quotidien** : page `/briefing` (encours, activité 24 h,
cartes vieillissantes, milestones proches), `GET /api/briefing`, et
envoi **ntfy** manuel (`POST /api/briefing/send`) ou automatique à
`KANBANTE_BRIEFING_TIME` via un planificateur interne (mémoire
d'envoi en base, aucun doublon après redémarrage).
- **Serveur MCP** (lecture seule) : JSON-RPC 2.0 sur `POST /mcp`
(initialize, tools/list, tools/call, ping) avec 7 outils —
`list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Aucune écriture,
conformément au principe « lectures d'abord ». `GET /mcp` décrit le
serveur.
- **Gestion des étiquettes sur le board** : modale de gestion
(lister / créer avec palette de couleurs / supprimer les étiquettes
du dépôt dans Gitea) et, dans le panneau détail, ajout / retrait
d'étiquettes sur l'issue en un clic, avec mise à jour immédiate de
la carte. API : `GET|POST /api/repos/{o}/{r}/labels`,
`DELETE /api/repos/{o}/{r}/labels/{id}`,
`POST /api/issues/{o}/{r}/{n}/labels`.
### Améliorations
- **Panneau Notes du projet** : repliable (bouton ⤢, état mémorisé par
projet), **largeur ajustable** par glisser (mémorisée), et
**sauvegarde automatique** après 1,2 s sans frappe avec indicateur
d'état (le bouton manuel reste). Le formulaire quitte HTMX pour le
même `board.js` que le reste du board.
- Navigation unifiée enrichie sur toutes les pages : Mes issues,
Briefing, Recherche.
### Tests
- 19 tests ajoutés (84 au total) : recherche et mes issues, briefing
(page, API, envoi ntfy, format), protocole MCP, API étiquettes,
marqueurs du board. Un doublon de déclaration JS attrapé par
`node --check` avant livraison.
## 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.
### Ajouts
- **Journal d'événements** (`board_events`, append-only) : chaque
création, déplacement, fermeture, réouverture et commentaire est
horodaté. Il alimente la page **`/activity`** (fil tous projets),
`GET /api/events` et `GET /api/metrics` (débit par type et temps de
cycle moyen : entrée en « En cours » → fermeture). Les fermetures
détectées lors de la réconciliation avec Gitea sont aussi journalisées
(source `gitea`). Un reclassement dans la même colonne n'écrit pas
d'événement.
- **Panneau détail de carte** : un clic sur une carte ouvre un panneau
latéral avec le corps de l'issue rendu en markdown (marked + DOMPurify,
vendorés), ses commentaires, assignés, milestone et échéance ; on peut
**commenter directement dans Gitea** depuis le panneau
(`GET /api/issues/{owner}/{repo}/{n}`, `POST .../comments`, 404 propre
si l'issue n'existe pas).
- **Vieillissement des cartes** : badge « ⏳ N j » dès qu'une carte reste
dans sa colonne au-delà de `KANBANTE_AGING_DAYS` (défaut 7, rouge au
double). Le temps en colonne vient du journal, avec repli sur
`board_state.updated_at` puis sur la date de création de l'issue.
- **Limites WIP** : `KANBANTE_WIP_LIMITS="in_progress:5,review:3"` —
l'en-tête de colonne affiche `n/limite`, passe en rouge en dépassement
et un toast avertit au dépôt. Limites souples : jamais bloquantes.
### Tests
- 16 tests ajoutés (52 au total) : journal et métriques, détail et
commentaires, vieillissement, WIP.
## 0.2.0 — 2026-10-11
Consolidation complète à partir d'un audit du code 0.1.0 (chaque correctif
+69
View File
@@ -9,7 +9,17 @@
- 🎯 **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
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
- 📝 **Notes** — bloc-notes par projet
- 🔐 **Authentification** Basic Auth intégrée + protection CSRF (voir Sécurité)
- 🌙 **Dark theme** — conçu pour les longues sessions
@@ -92,6 +102,14 @@ sudo systemctl enable --now docker
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
| `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 |
| `KANBANTE_NTFY_URL` | *(vide)* | Base du serveur ntfy (ex. `https://ntfy.sh` ou auto-hébergé) pour le briefing |
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
## Sécurité
@@ -134,8 +152,59 @@ kanbante/
| GET | `/` | Dashboard tous les projets |
| GET | `/board/{owner}/{repo}` | Kanban board |
| GET | `/focus` | En cours + En révision, tous projets actifs |
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
| GET | `/api/issues/{owner}/{repo}/{n}` | Détail d'une issue + commentaires (panneau) |
| 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) |
| GET | `/search?q=` | Recherche globale d'issues (tous projets, tous états) |
| GET | `/my` | Issues ouvertes assignées à l'utilisateur du jeton |
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
```json
{
"mcpServers": {
"kanbante": {
"url": "https://<votre-hôte-kanbante>/mcp"
}
}
}
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
## 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) |
+71
View File
@@ -0,0 +1,71 @@
"""Daily briefing: formatting and ntfy delivery.
The data itself is gathered in app.main (it needs the board helpers);
this module only turns it into a readable text and pushes it to an
ntfy topic. Kept dependency-light so it is unit-testable alone.
"""
from __future__ import annotations
import httpx
from app.config import Settings
def ntfy_configured(settings: Settings) -> bool:
return bool(settings.ntfy_url and settings.ntfy_topic)
def format_briefing_text(data: dict) -> str:
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
activity = data["activity_24h"]
lines.append(
f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), "
f"{activity['created']} créée(s), {activity['moved']} déplacement(s)."
)
if activity.get("avg_cycle_days") is not None:
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
for group in data["focus_groups"][:6]:
lines.append(f" • {group['full_name']} : {group['count']}")
for title in group["titles"][:3]:
lines.append(f" – {title}")
if data["aging"]:
lines.append("\n⏳ Cartes qui vieillissent :")
for card in data["aging"][:8]:
lines.append(
f" • {card['repo']} #{card['number']} « {card['title']} » "
f"— {card['days']} j en {card['column_label']}"
)
if data["milestones_due"]:
lines.append("\n📅 Milestones proches :")
for ms in data["milestones_due"][:5]:
lines.append(
f" • {ms['repo']} « {ms['title']} » — échéance "
f"{ms['due_on']} ({ms['open']} ouverte(s))"
)
if data["focus_total"] == 0 and not data["aging"]:
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
return "\n".join(lines)
async def send_ntfy(settings: Settings, text: str) -> bool:
"""POST the briefing to the configured ntfy topic. True on 2xx."""
if not ntfy_configured(settings):
return False
url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
headers = {
# ntfy headers must stay ASCII; the body carries the accents.
"Title": "Kanbante - briefing du jour",
"Tags": "clipboard,chart_with_upwards_trend",
}
if settings.ntfy_token:
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(url, content=text.encode("utf-8"), headers=headers)
return response.status_code < 300
+46 -1
View File
@@ -17,6 +17,25 @@ Environment variables:
KANBANTE_MAX_NOTE_CHARS Maximum size of a project note (default 20000).
KANBANTE_SYNC_GITEA "1" (default) = moving a card to/from "done"
closes / reopens the Gitea issue.
KANBANTE_WIP_LIMITS Comma list "column:limit" (e.g.
"in_progress:5,review:3"). Columns not
listed are unlimited. Soft limits: the UI
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>.
KANBANTE_NTFY_URL Base URL of an ntfy server (e.g.
https://ntfy.sh or self-hosted).
KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing.
KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy.
KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which
the daily briefing is sent to ntfy.
Empty (default) = no automatic send,
the /briefing page stays available.
"""
import os
@@ -25,7 +44,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.2.0"
APP_VERSION = "0.5.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -51,6 +70,16 @@ def _env_float(name: str, default: float) -> float:
return default
def _env_wip_limits(name: str) -> dict[str, int]:
limits: dict[str, int] = {}
for chunk in os.getenv(name, "").split(","):
column, sep, value = chunk.partition(":")
column = column.strip()
if sep and column and value.strip().isdigit():
limits[column] = int(value.strip())
return limits
@dataclass(frozen=True)
class Settings:
gitea_url: str = "https://git.dracodev.net"
@@ -63,6 +92,14 @@ class Settings:
cache_ttl: float = 30.0
max_note_chars: int = 20_000
sync_gitea: bool = True
wip_limits: dict | None = None
aging_days: int = 7
webhook_secret: str = ""
sync_labels: bool = True
ntfy_url: str = ""
ntfy_topic: str = ""
ntfy_token: str = ""
briefing_time: str = ""
@property
def auth_enabled(self) -> bool:
@@ -84,4 +121,12 @@ def get_settings() -> Settings:
cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0),
max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000),
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),
ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""),
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
)
+117
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:
@@ -198,6 +202,119 @@ class GiteaClient:
issues_by_repo[full_name] = []
return issues_by_repo
async def get_issue(self, owner: str, repo: str, number: int) -> dict:
"""One issue with body, labels, assignees, milestone."""
return await self._get(f"/repos/{owner}/{repo}/issues/{number}")
async def get_issue_comments(
self, owner: str, repo: str, number: int
) -> list[dict]:
"""All comments of an issue (paginated)."""
return await self._get_all_pages(
f"/repos/{owner}/{repo}/issues/{number}/comments"
)
async def create_comment(
self, owner: str, repo: str, number: int, body: str
) -> dict:
"""Post a comment on an issue and return it."""
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/comments",
json={"body": body},
)
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()
async def delete_label(self, owner: str, repo: str, label_id: int) -> None:
"""Delete a label definition from the repo (all issues lose it)."""
r = await self.client.delete(f"/repos/{owner}/{repo}/labels/{label_id}")
r.raise_for_status()
self.forget_label_cache(owner, repo)
def forget_label_cache(self, owner: str, repo: str) -> None:
cache = self.__dict__.get("_label_cache")
if cache:
cache.pop(f"{owner}/{repo}", None)
# ── Issue search (global + assigned) ───────────────────
async def search_issues(
self,
q: str = "",
state: str = "open",
assigned_by: str | None = None,
) -> list[dict]:
"""Search issues across every repo visible to the token.
Uses Gitea's /repos/issues/search (type=issues excludes PRs).
Results carry their repository in issue["repository"].
"""
params: dict[str, Any] = {"state": state, "type": "issues"}
if q:
params["q"] = q
if assigned_by:
params["assigned_by"] = assigned_by
return await self._get_all_pages("/repos/issues/search", **params)
# ── 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:
+980 -10
View File
File diff suppressed because it is too large Load Diff
+173 -1
View File
@@ -65,8 +65,27 @@ def init_db() -> None:
UNIQUE(repo_full_name)
);
CREATE TABLE IF NOT EXISTS board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_column TEXT,
to_column TEXT,
source TEXT NOT NULL DEFAULT 'ui',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
)
_migrate(conn)
@@ -189,7 +208,8 @@ def reconcile_board_state(
- Rows for issues that no longer exist in Gitea are deleted.
Returns counters of what changed.
"""
changed = {"closed_to_done": 0, "purged": 0}
changed: dict = {"closed_to_done": 0, "purged": 0,
"closed_numbers": [], "purged_numbers": []}
known = open_numbers | closed_numbers
with get_db() as conn:
rows = conn.execute(
@@ -206,6 +226,7 @@ def reconcile_board_state(
(repo_full_name, number),
)
changed["purged"] += 1
changed["purged_numbers"].append(number)
elif number in closed_numbers and row["column_name"] != "done":
conn.execute(
"UPDATE board_state SET column_name = 'done', "
@@ -214,9 +235,160 @@ def reconcile_board_state(
(repo_full_name, number),
)
changed["closed_to_done"] += 1
changed["closed_numbers"].append(number)
return changed
def get_kv(key: str) -> str | None:
with get_db() as conn:
row = conn.execute(
"SELECT value FROM kv_store WHERE key = ?", (key,)
).fetchone()
return row["value"] if row else None
def set_kv(key: str, value: str) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO kv_store (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
# ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
def log_event(
repo_full_name: str,
issue_number: int,
event_type: str,
from_column: str | None = None,
to_column: str | None = None,
source: str = "ui",
created_at: str | None = None,
) -> None:
"""Append one entry to the board's event journal.
The journal is append-only: it is the memory board_state lacks
(time-in-column, activity feed, throughput / cycle-time metrics).
"""
if event_type not in EVENT_TYPES:
raise ValueError(f"Invalid event type: {event_type}")
with get_db() as conn:
if created_at:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source, created_at),
)
else:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source),
)
def get_events(
repo_full_name: str | None = None, limit: int = 100
) -> list[dict]:
"""Most recent journal entries, newest first (one repo or all)."""
with get_db() as conn:
if repo_full_name:
rows = conn.execute(
"SELECT * FROM board_events WHERE repo_full_name = ? "
"ORDER BY id DESC LIMIT ?",
(repo_full_name, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM board_events ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def column_entry_times(repo_full_name: str) -> dict[int, str]:
"""When each issue entered its current column, per the journal.
Latest event per issue, comments excluded (a comment does not move
a card). Issues without journal entries are absent — callers fall
back to board_state.updated_at, then to the issue's creation date.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, MAX(created_at) AS entered_at "
"FROM board_events WHERE repo_full_name = ? "
"AND event_type != 'commented' GROUP BY issue_number",
(repo_full_name,),
).fetchall()
return {row["issue_number"]: row["entered_at"] for row in rows}
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
"""Throughput and cycle time computed from the journal.
Cycle time = time from entering in_progress (or from creation, when
no in_progress entry exists) to the closing event, averaged over
issues closed inside the window.
"""
from datetime import datetime, timedelta, timezone
def _parse(value: str):
try:
return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return None
with get_db() as conn:
if repo_full_name:
rows = conn.execute(
"SELECT repo_full_name, issue_number, event_type, to_column, "
"created_at FROM board_events WHERE repo_full_name = ? "
"ORDER BY created_at, id",
(repo_full_name,),
).fetchall()
else:
rows = conn.execute(
"SELECT repo_full_name, issue_number, event_type, to_column, "
"created_at FROM board_events ORDER BY created_at, id"
).fetchall()
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
counts: dict[str, int] = {}
starts: dict[tuple, datetime] = {}
cycles: list[float] = []
for row in rows:
at = _parse(row["created_at"])
if at is None:
continue
if at >= cutoff:
counts[row["event_type"]] = counts.get(row["event_type"], 0) + 1
key = (row["repo_full_name"], row["issue_number"])
if row["event_type"] == "created":
starts.setdefault(key, at)
elif row["event_type"] == "moved" and row["to_column"] == "in_progress":
starts[key] = at # cycle clock (re)starts on entering in_progress
elif row["event_type"] == "closed" and key in starts and at >= cutoff:
cycles.append((at - starts[key]).total_seconds() / 86400.0)
return {
"window_days": window_days,
"created": counts.get("created", 0),
"moved": counts.get("moved", 0),
"closed": counts.get("closed", 0),
"reopened": counts.get("reopened", 0),
"avg_cycle_days": round(sum(cycles) / len(cycles), 2) if cycles else None,
"cycle_samples": len(cycles),
}
def set_notes(repo_full_name: str, note: str) -> None:
with get_db() as conn:
conn.execute(
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Activité — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📰</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<main class="focus-main">
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
{% if not events %}
<p class="focus-empty">Aucun événement pour le moment. Le journal se remplit dès que des cartes bougent.</p>
{% endif %}
<div class="activity-list">
{% for e in events %}
<div class="activity-item">
<span class="activity-icon">{{ event_icons.get(e.event_type, '•') }}</span>
<div class="activity-text">
<a href="/board/{{ e.repo_full_name }}">{{ e.repo_full_name }}</a>
<strong>#{{ e.issue_number }}</strong>
{% if e.event_type == 'created' %}
créée{% if e.to_column %} dans {{ column_labels.get(e.to_column, e.to_column) }}{% endif %}
{% elif e.event_type == 'moved' %}
déplacée{% if e.from_column %} de {{ column_labels.get(e.from_column, e.from_column) }}{% endif %} vers {{ column_labels.get(e.to_column, e.to_column) }}
{% elif e.event_type == 'closed' %}
fermée{% if e.source == 'gitea' %} (dans Gitea){% endif %}
{% elif e.event_type == 'reopened' %}
rouverte vers {{ column_labels.get(e.to_column, e.to_column) }}
{% elif e.event_type == 'commented' %}
commentée
{% endif %}
<span class="activity-date">{{ e.created_at[:16].replace('T', ' ') }}</span>
</div>
</div>
{% endfor %}
</div>
</main>
<footer>
Kanbante v{{ version }} · journal d'événements
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+120 -32
View File
@@ -6,34 +6,63 @@
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<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>
<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="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</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 %}
<option value="{{ label.name }}">{{ label.name }}</option>
{% endfor %}
</select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
@@ -48,9 +77,14 @@
data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
<span class="column-title">{{ column_labels[col] }}</span>
<span class="column-count">{{ columns[col]|length }}</span>
{% set limit = wip_limits.get(col) %}
<span class="column-count{% if limit and columns[col]|length > limit %} over{% endif %}"
{% 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 }}"
@@ -78,6 +112,11 @@
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
{% endif %}
<span class="card-date">{{ issue.created_at[:10] }}</span>
{% set age = ages.get(issue.number) %}
{% if age and age.days >= aging_days %}
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
</div>
</div>
{% endfor %}
@@ -86,34 +125,83 @@
{% endfor %}
</div>
<!-- Notes panel -->
<aside class="notes-panel">
<h3>📝 Notes du projet</h3>
<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 }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="note" rows="10"
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</form>
<!-- Notes panel: collapsible + resizable + autosave -->
<aside class="notes-panel" id="notes-panel">
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
<div class="notes-head">
<h3>📝 Notes du projet</h3>
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
title="Replier / déplier le panneau">⤢</button>
</div>
<div class="notes-body-wrap">
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea. Enregistrement automatique.</p>
<form id="notes-form">
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
<textarea name="note" id="notes-text" rows="10"
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<div class="notes-foot">
<span id="notes-status" class="notes-status"></span>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</div>
</form>
</div>
</aside>
</main>
<!-- Label manager modal -->
<div id="label-modal" class="modal" hidden>
<div class="modal-box">
<div class="detail-head">
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
</div>
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
<div id="label-list" class="label-list"></div>
<form id="label-create-form" class="label-create-form">
<input type="text" id="new-label-name" maxlength="50" placeholder="Nom de la nouvelle étiquette" required>
<input type="color" id="new-label-color" value="#7c5cfc" title="Couleur">
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
<div class="label-swatches">
{% for c in ['e11d48','f59e0b','10b981','0ea5e9','7c5cfc','ec4899','64748b','84cc16'] %}
<button type="button" class="swatch" data-color="#{{ c }}" style="background: #{{ c }}" title="#{{ c }}"></button>
{% endfor %}
</div>
</div>
</div>
<!-- Card detail drawer: issue body, comments, comment form -->
<div id="detail-overlay" class="detail-overlay" hidden></div>
<aside id="card-detail" class="card-detail" hidden>
<div class="detail-head">
<span id="detail-number" class="card-id"></span>
<span id="detail-state" class="chip"></span>
<button id="detail-close" class="btn-sm" type="button">✕</button>
</div>
<h2 id="detail-title"></h2>
<div id="detail-meta" class="detail-meta"></div>
<div id="detail-labels" class="card-labels"></div>
<div class="detail-labels-manage">
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
<div id="detail-label-toggles" class="label-toggles"></div>
</div>
<div id="detail-body" class="detail-body"></div>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
<form id="comment-form">
<textarea id="comment-body" rows="3" maxlength="10000"
placeholder="Écrire un commentaire (markdown)..."></textarea>
<button type="submit" class="btn-save">💬 Commenter dans Gitea</button>
</form>
<p class="detail-foot"><a id="detail-gitea-link" href="#" target="_blank">Ouvrir dans Gitea ↗</a></p>
</aside>
<footer>
<span>{{ full_name }}</span>
{% if repo_info.updated_at %}
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
<script>
function showNotesSaved() {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = '✅ Notes sauvegardées';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
</script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Briefing — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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>Briefing</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<main class="focus-main">
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
<div class="stat-tiles briefing-tiles">
<div class="stat-tile"><span class="stat-num">{{ data.focus_total }}</span><span class="stat-label">en cours / révision</span></div>
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.closed }}</span><span class="stat-label">fermées · 24 h</span></div>
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.moved }}</span><span class="stat-label">déplacements · 24 h</span></div>
<div class="stat-tile"><span class="stat-num">{{ data.aging|length }}</span><span class="stat-label">cartes vieillissantes</span></div>
</div>
<section class="briefing-card">
<h2>📄 Contenu du briefing</h2>
<pre class="briefing-text">{{ text }}</pre>
</section>
<section class="briefing-card">
<h2>📲 Envoi ntfy</h2>
{% if ntfy_configured %}
<p class="notes-hint">ntfy est configuré. {% if briefing_time %}Envoi automatique chaque jour à <strong>{{ briefing_time }}</strong> (heure du serveur).{% else %}Aucune heure d'envoi automatique (<code>KANBANTE_BRIEFING_TIME</code> non défini).{% endif %}{% if last_sent %} Dernier envoi : {{ last_sent }}.{% endif %}</p>
<form id="briefing-send-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">📤 Envoyer maintenant via ntfy</button>
</form>
<p id="briefing-status" class="notes-hint"></p>
{% else %}
<p class="notes-hint">Pour recevoir ce briefing sur votre téléphone, définissez <code>KANBANTE_NTFY_URL</code> (ex. <code>https://ntfy.sh</code> ou votre ntfy auto-hébergé), <code>KANBANTE_NTFY_TOPIC</code> et, pour l'envoi automatique, <code>KANBANTE_BRIEFING_TIME=07:30</code>, puis redémarrez Kanbante.</p>
{% endif %}
</section>
</main>
<footer>
Kanbante v{{ version }} · briefing quotidien
</footer>
<script src="/static/js/live.js" defer></script>
<script>
document.getElementById('briefing-send-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('briefing-status');
status.textContent = 'Envoi en cours...';
const r = await fetch('/api/briefing/send', { method: 'POST', body: new FormData(e.target) });
const data = await r.json().catch(() => ({}));
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
});
</script>
</body>
</html>
+54 -8
View File
@@ -9,15 +9,33 @@
<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>
<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="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</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). -->
@@ -36,7 +54,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) %}
@@ -52,8 +71,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'] %}
@@ -72,19 +100,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>
+11 -2
View File
@@ -7,8 +7,17 @@
<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="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<main class="error-main">
<div class="error-box">
+13 -3
View File
@@ -8,9 +8,18 @@
<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="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -58,5 +67,6 @@
<footer>
Kanbante v{{ version }} · vue Focus
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes issues — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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>Mes issues</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<main class="focus-main">
{% if not user %}
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
{% else %}
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
{% if not groups %}
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
<span class="badge secondary">{{ group.rows|length }}</span></h2>
<div class="issue-results">
{% for row in group.rows %}
{% set issue = row.issue %}
<div class="issue-row">
<span class="issue-state">🟢</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
</a>
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
{% for label in issue.labels or [] %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
{% endfor %}
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% endif %}
</main>
<footer>
Kanbante v{{ version }} · mes issues
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recherche — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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>Recherche</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
</nav>
</header>
<main class="focus-main">
<form method="get" action="/search" class="search-hero">
<input type="text" name="q" value="{{ q }}" autofocus
placeholder="Rechercher dans toutes les issues de tous les projets...">
<button type="submit" class="btn-save btn-inline">Rechercher</button>
</form>
{% if q %}
<p class="focus-intro">{{ total }} résultat(s) pour « {{ q }} » — recherche Gitea, tous projets confondus.</p>
{% if not groups %}
<div class="empty-state">🔍 Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
<span class="badge secondary">{{ group.rows|length }}</span></h2>
<div class="issue-results">
{% for row in group.rows %}
{% set issue = row.issue %}
<div class="issue-row">
<span class="issue-state">{{ '✅' if issue.state != 'open' else '🟢' }}</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
</a>
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
{% for label in issue.labels or [] %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
{% endfor %}
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% else %}
<div class="empty-state">🔍 Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
{% endif %}
</main>
<footer>
Kanbante v{{ version }} · recherche globale
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+5 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.2.0"
version = "0.5.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
@@ -24,3 +24,7 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
# Always put the repo root on sys.path: the plain `pytest` binary (CI)
# does not add the CWD, unlike `python -m pytest` — without this,
# `from tests.conftest import ...` fails at collection time in CI.
pythonpath = ["."]
+688 -185
View File
File diff suppressed because it is too large Load Diff
+359 -1
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();
@@ -50,6 +52,11 @@ document.addEventListener('DOMContentLoaded', () => {
})
.then(data => {
updateCounts();
const targetCol = evt.to.closest('.kanban-column');
const badge = targetCol?.querySelector('.column-count');
if (badge?.classList.contains('over')) {
showToast('⚠️ Limite WIP dépassée dans ' + evt.to.dataset.column.replace('_', ' ') + ' (' + badge.textContent + ')', true);
}
let msg = '✅ Déplacé vers ' + evt.to.dataset.column.replace('_', ' ');
if (data.gitea_sync === 'closed') msg += ' · issue fermée dans Gitea';
if (data.gitea_sync === 'reopened') msg += ' · issue rouverte dans Gitea';
@@ -112,10 +119,361 @@ document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.kanban-column').forEach(col => {
const count = col.querySelectorAll('.kanban-card').length;
const badge = col.querySelector('.column-count');
if (badge) badge.textContent = count;
if (!badge) return;
const limit = parseInt(badge.dataset.limit || '0', 10);
if (limit > 0) {
badge.textContent = count + '/' + limit;
badge.classList.toggle('over', count > limit);
} else {
badge.textContent = count;
}
});
}
// 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();
}
});
// ── Repo context (repoFullName is declared at the top) ──
const [repoOwner, repoName] = (repoFullName || '').split('/', 2);
// ── Notes panel: collapsible, resizable, autosave ──────
const notesPanel = document.getElementById('notes-panel');
if (notesPanel) {
const widthKey = 'kb.notes.w.' + repoFullName;
const collapsedKey = 'kb.notes.c.' + repoFullName;
const savedWidth = parseInt(localStorage.getItem(widthKey) || '0', 10);
if (savedWidth >= 200 && savedWidth <= 560) notesPanel.style.width = savedWidth + 'px';
const toggleBtn = document.getElementById('notes-toggle');
function applyCollapsed(collapsed) {
notesPanel.classList.toggle('collapsed', collapsed);
toggleBtn.textContent = collapsed ? '⤡' : '⤢';
toggleBtn.title = collapsed ? 'Déplier le panneau' : 'Replier le panneau';
}
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
toggleBtn?.addEventListener('click', () => {
const collapsed = !notesPanel.classList.contains('collapsed');
applyCollapsed(collapsed);
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
});
const handle = document.getElementById('notes-resize');
handle?.addEventListener('pointerdown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = notesPanel.getBoundingClientRect().width;
function onMove(ev) {
const w = Math.min(560, Math.max(200, startW + (startX - ev.clientX)));
notesPanel.style.width = w + 'px';
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
localStorage.setItem(widthKey, String(Math.round(notesPanel.getBoundingClientRect().width)));
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
});
const notesText = document.getElementById('notes-text');
const notesStatus = document.getElementById('notes-status');
let notesTimer = null;
async function saveNotes() {
const fd = new FormData();
fd.append('repo_full_name', repoFullName);
fd.append('note', notesText.value);
fd.append('csrf_token', csrfToken);
notesStatus.textContent = '💾 Enregistrement...';
try {
const r = await fetch('/api/notes', { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
notesStatus.textContent = '✓ Enregistré à ' + new Date().toLocaleTimeString('fr-CA');
} catch (err) {
notesStatus.textContent = '❌ Échec : ' + err.message;
}
}
notesText?.addEventListener('input', () => {
notesStatus.textContent = '✎ Modifié...';
clearTimeout(notesTimer);
notesTimer = setTimeout(saveNotes, 1200);
});
document.getElementById('notes-form')?.addEventListener('submit', (e) => {
e.preventDefault();
clearTimeout(notesTimer);
saveNotes();
});
}
// ── Label manager ──────────────────────────────────────
let repoLabelsCache = null;
async function fetchRepoLabels() {
if (repoLabelsCache) return repoLabelsCache;
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = data.labels;
return repoLabelsCache;
}
function labelChipEl(label) {
const span = document.createElement('span');
span.className = 'label-tag';
const color = /^[0-9a-fA-F]{6}$/.test(label.color || '') ? label.color : '888888';
span.style.background = '#' + color + '30';
span.style.color = '#' + color;
span.textContent = label.name;
return span;
}
const labelModal = document.getElementById('label-modal');
async function renderLabelList() {
const box = document.getElementById('label-list');
box.innerHTML = '';
try {
const labels = await fetchRepoLabels();
if (!labels.length) box.innerHTML = '<p class="detail-empty">Aucune étiquette dans ce dépôt.</p>';
labels.forEach(label => {
const row = document.createElement('div');
row.className = 'label-row';
row.appendChild(labelChipEl(label));
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-sm label-del';
del.textContent = '🗑️ Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer l\'étiquette « ' + label.name + ' » de tout le dépôt ?')) return;
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels/${label.id}`,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = repoLabelsCache.filter(l => l.id !== label.id);
row.remove();
showToast('✅ Étiquette supprimée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
} catch (err) {
box.innerHTML = '<p class="detail-empty">Erreur : ' + err.message + '</p>';
}
}
document.getElementById('labels-btn')?.addEventListener('click', () => {
labelModal.hidden = false;
renderLabelList();
});
document.getElementById('label-modal-close')?.addEventListener('click', () => { labelModal.hidden = true; });
labelModal?.addEventListener('click', (e) => { if (e.target === labelModal) labelModal.hidden = true; });
document.querySelectorAll('.swatch').forEach(sw => sw.addEventListener('click', () => {
document.getElementById('new-label-color').value = sw.dataset.color;
}));
document.getElementById('label-create-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('new-label-name').value.trim();
const color = document.getElementById('new-label-color').value.replace('#', '');
if (!name) return;
const fd = new FormData();
fd.append('name', name);
fd.append('color', color);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
if (repoLabelsCache) repoLabelsCache.push(data.label);
document.getElementById('new-label-name').value = '';
renderLabelList();
showToast('✅ Étiquette « ' + data.label.name + ' » créée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => {
card.addEventListener('click', (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (e.target.closest('a')) return;
openDetail(card.dataset.repo, card.dataset.issue);
});
});
const drawer = document.getElementById('card-detail');
const overlay = document.getElementById('detail-overlay');
let currentIssue = null; // {repo, number}
function renderMarkdown(text) {
if (window.marked && window.DOMPurify) {
return DOMPurify.sanitize(marked.parse(text || ''));
}
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function closeDetail() {
drawer.hidden = true;
overlay.hidden = true;
currentIssue = null;
}
document.getElementById('detail-close')?.addEventListener('click', closeDetail);
overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number };
drawer.hidden = false;
overlay.hidden = false;
document.getElementById('detail-number').textContent = '#' + number;
document.getElementById('detail-title').textContent = 'Chargement...';
document.getElementById('detail-body').innerHTML = '';
document.getElementById('detail-comments').innerHTML = '';
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, data.comments);
} catch (err) {
document.getElementById('detail-title').textContent = 'Erreur de chargement';
showToast('❌ Détail indisponible : ' + err.message, true);
}
}
function fillDetail(issue, comments) {
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
const meta = [];
if (issue.milestone) meta.push('🎯 ' + issue.milestone);
if (issue.due_date) meta.push('📅 échéance ' + String(issue.due_date).slice(0, 10));
meta.push('créée le ' + String(issue.created_at).slice(0, 10));
for (const a of issue.assignees || []) {
meta.push('👤 ' + a.login);
}
document.getElementById('detail-meta').textContent = meta.join(' · ');
const labelsBox = document.getElementById('detail-labels');
labelsBox.innerHTML = '';
(issue.labels || []).forEach(l => {
const span = document.createElement('span');
span.className = 'label-tag';
const color = /^[0-9a-fA-F]{6}$/.test(l.color || '') ? l.color : '888888';
span.style.background = '#' + color + '30';
span.style.color = '#' + color;
span.textContent = l.name;
labelsBox.appendChild(span);
});
renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments');
box.innerHTML = '';
(comments || []).forEach(c => box.appendChild(commentNode(c)));
if (!comments || comments.length === 0) {
box.innerHTML = '<p class="detail-empty">Aucun commentaire.</p>';
}
}
async function renderLabelToggles(issue) {
const box = document.getElementById('detail-label-toggles');
if (!box) return;
box.innerHTML = '';
let all = [];
try { all = await fetchRepoLabels(); } catch { return; }
const activeIds = new Set((issue.labels || []).map(l => l.id));
all.forEach(label => {
const chip = labelChipEl(label);
chip.classList.add('label-toggle');
if (activeIds.has(label.id)) chip.classList.add('on');
chip.title = activeIds.has(label.id) ? 'Retirer de l\'issue' : 'Ajouter à l\'issue';
chip.addEventListener('click', async () => {
const action = activeIds.has(label.id) ? 'remove' : 'add';
const fd = new FormData();
fd.append('label_id', label.id);
fd.append('action', action);
fd.append('csrf_token', csrfToken);
try {
const [owner, repo] = currentIssue.repo.split('/', 2);
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/labels`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
issue.labels = data.labels;
// Refresh the chips in the drawer and on the board card.
const labelsBox = document.getElementById('detail-labels');
labelsBox.innerHTML = '';
data.labels.forEach(l => labelsBox.appendChild(labelChipEl(l)));
updateCardLabels(currentIssue.number, data.labels);
renderLabelToggles(issue);
} catch (err) { showToast('❌ ' + err.message, true); }
});
box.appendChild(chip);
});
}
function updateCardLabels(number, labels) {
const card = document.querySelector(`.kanban-card[data-issue="${number}"]`);
if (!card) return;
card.dataset.labels = labels.map(l => (l.name || '').toLowerCase()).join(',');
let box = card.querySelector('.card-labels');
if (!box && labels.length) {
box = document.createElement('div');
box.className = 'card-labels';
card.querySelector('.card-title')?.after(box);
}
if (box) {
box.innerHTML = '';
labels.forEach(l => box.appendChild(labelChipEl(l)));
box.style.display = labels.length ? '' : 'none';
}
}
function commentNode(c) {
const div = document.createElement('div');
div.className = 'comment';
const head = document.createElement('div');
head.className = 'comment-head';
head.textContent = '👤 ' + (c.user?.login || '?') + ' · ' + String(c.created_at).slice(0, 16).replace('T', ' ');
const bodyEl = document.createElement('div');
bodyEl.className = 'comment-body';
bodyEl.innerHTML = renderMarkdown(c.body || '');
div.appendChild(head);
div.appendChild(bodyEl);
return div;
}
document.getElementById('comment-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const textarea = document.getElementById('comment-body');
const body = textarea.value.trim();
if (!body) return;
const [owner, repo] = currentIssue.repo.split('/', 2);
const formData = new FormData();
formData.append('body', body);
formData.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/comments`, { method: 'POST', body: formData });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
const box = document.getElementById('detail-comments');
if (box.querySelector('.detail-empty')) box.innerHTML = '';
box.appendChild(commentNode(data.comment));
textarea.value = '';
showToast('✅ Commentaire publié dans Gitea');
} catch (err) {
showToast('❌ Échec du commentaire : ' + err.message, true);
}
});
function showToast(msg, isError = false) {
const toast = document.createElement('div');
toast.className = 'toast' + (isError ? ' error' : '');
+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);
})();
+6
View File
File diff suppressed because one or more lines are too long
+3
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
"""Kanbante test suite.
This package marker matters: with it, pytest puts the repository root on
sys.path, so `from tests.conftest import ...` resolves identically
whether the suite is launched via the `pytest` binary (CI) or via
`python -m pytest` (which additionally adds the CWD). The root is also
pinned explicitly through `pythonpath` in pyproject.toml.
"""
+128 -2
View File
@@ -45,10 +45,15 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
issue = {
"number": number,
"title": title,
"body": f"Corps de l'issue {number} en **markdown**.",
"state": state,
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": None,
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": {"login": "bruno", "avatar_url": ""} if number == 1 else None,
"assignees": [{"login": "bruno", "avatar_url": ""}] if number == 1 else [],
"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",
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
}
if pr:
@@ -74,8 +79,25 @@ 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]] = {
("Projets/demo", 1): [
{
"id": 1,
"body": "Premier commentaire",
"created_at": "2026-09-03T10:00:00Z",
"updated_at": "2026-09-03T10:00:00Z",
"html_url": "http://gitea.test/comment/1",
"user": {"login": "bruno", "avatar_url": ""},
}
],
}
self.posted_comments: list[tuple] = []
self.ping_result = True
self.raise_on_search = False
self.fail_state_change = False
@@ -87,6 +109,37 @@ class FakeGitea:
async def ping(self) -> bool:
return self.ping_result
async def get_user(self):
return {"id": 1, "login": "bruno"}
async def search_issues(self, q="", state="open", assigned_by=None):
results = []
for full_name, issues in self.issues.items():
for issue in issues:
if issue.get("pull_request"):
continue
if state != "all" and issue["state"] != state:
continue
if q and q.lower() not in issue["title"].lower():
continue
if assigned_by:
logins = [a["login"] for a in issue.get("assignees") or []]
if issue.get("assignee"):
logins.append(issue["assignee"]["login"])
if assigned_by not in logins:
continue
results.append({**issue, "repository": {"full_name": full_name}})
return results
async def delete_label(self, owner, repo, label_id):
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
for name in [n for n, l in labels.items() if l["id"] == label_id]:
del labels[name]
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
def forget_label_cache(self, owner, repo):
pass
async def search_repos(self):
if self.raise_on_search:
raise httpx.HTTPError("simulated Gitea outage")
@@ -105,6 +158,78 @@ class FakeGitea:
items = [i for i in items if i["state"] == state]
return items
async def get_issue(self, owner, repo, number):
import httpx as _httpx
for issue in self.issues.get(f"{owner}/{repo}", []):
if issue["number"] == number:
return issue
request = _httpx.Request("GET", "http://gitea.test")
raise _httpx.HTTPStatusError(
"404", request=request, response=_httpx.Response(404, request=request)
)
async def get_issue_comments(self, owner, repo, number):
return list(self.comments.get((f"{owner}/{repo}", number), []))
async def create_comment(self, owner, repo, number, body):
comment = {
"id": 500 + len(self.posted_comments),
"body": body,
"created_at": "2026-10-11T10:00:00Z",
"updated_at": "2026-10-11T10:00:00Z",
"html_url": "http://gitea.test/comment/new",
"user": {"login": "bruno", "avatar_url": ""},
}
self.comments.setdefault((f"{owner}/{repo}", number), []).append(comment)
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)
@@ -129,6 +254,7 @@ def clean_db():
with models.get_db() as conn:
conn.execute("DELETE FROM board_state")
conn.execute("DELETE FROM project_notes")
conn.execute("DELETE FROM board_events")
yield
+3 -1
View File
@@ -5,10 +5,12 @@ from tests.conftest import csrf_from
def test_health_is_public_liveness(client):
from app.config import APP_VERSION
r = client.get("/api/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
assert r.json()["version"] == "0.2.0"
assert r.json()["version"] == APP_VERSION
def test_readiness_reflects_gitea(client, fake_gitea):
+2 -1
View File
@@ -76,7 +76,8 @@ def test_reconcile_closed_to_done_and_purge_deleted():
models.move_issue("a/b", 2, "todo")
models.move_issue("a/b", 3, "review")
changed = models.reconcile_board_state("a/b", open_numbers={1}, closed_numbers={2})
assert changed == {"closed_to_done": 1, "purged": 1}
assert changed["closed_to_done"] == 1 and changed["purged"] == 1
assert changed["closed_numbers"] == [2] and changed["purged_numbers"] == [3]
state = models.get_board_state("a/b")
assert [c["issue_number"] for c in state["in_progress"]] == [1]
assert [c["issue_number"] for c in state["done"]] == [2]
+183
View File
@@ -0,0 +1,183 @@
"""Palier 1 (0.3.0): event journal, card detail panel, aging + WIP limits."""
from datetime import datetime, timedelta, timezone
from app import models
from tests.conftest import csrf_from
def _days_ago(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
# ── Journal ────────────────────────────────────────────────
def test_log_event_and_get_events():
models.log_event("a/b", 1, "created", to_column="backlog")
models.log_event("a/b", 1, "moved", from_column="backlog", to_column="todo")
events = models.get_events("a/b")
assert [e["event_type"] for e in events] == ["moved", "created"]
assert events[0]["from_column"] == "backlog"
def test_log_event_rejects_unknown_type():
import pytest
with pytest.raises(ValueError):
models.log_event("a/b", 1, "exploded")
def test_move_via_api_writes_journal(client):
token = csrf_from(client)
client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"to_column": "done", "from_column": "backlog",
"to_order": "1", "csrf_token": token,
},
)
types = [e["event_type"] for e in models.get_events("Projets/demo")]
assert types == ["closed", "moved"] # newest first
def test_same_column_reorder_does_not_log_move(client):
token = csrf_from(client)
client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo", "issue_number": 5,
"to_column": "backlog", "from_column": "backlog",
"to_order": "5,1", "csrf_token": token,
},
)
assert models.get_events("Projets/demo") == []
def test_create_issue_logs_created_event(client):
token = csrf_from(client)
client.post(
"/api/issues",
data={
"repo_full_name": "Projets/demo", "title": "Nouvelle",
"column": "todo", "csrf_token": token,
},
)
events = models.get_events("Projets/demo")
assert events and events[0]["event_type"] == "created"
assert events[0]["to_column"] == "todo"
def test_events_api_and_activity_page(client):
models.log_event("Projets/demo", 1, "moved",
from_column="backlog", to_column="review")
r = client.get("/api/events?repo=Projets/demo")
assert r.status_code == 200
assert r.json()["events"][0]["event_type"] == "moved"
r = client.get("/activity")
assert r.status_code == 200
assert "Projets/demo" in r.text
def test_metrics_counts_and_cycle_time():
models.log_event("a/b", 1, "created", to_column="backlog",
created_at=_days_ago(10))
models.log_event("a/b", 1, "moved", from_column="backlog",
to_column="in_progress", created_at=_days_ago(9))
models.log_event("a/b", 1, "closed", to_column="done",
created_at=_days_ago(4))
metrics = models.get_metrics("a/b", window_days=30)
assert metrics["created"] == 1
assert metrics["closed"] == 1
assert metrics["avg_cycle_days"] == 5.0 # in_progress (J-9) -> closed (J-4)
r = models.get_metrics("a/b", window_days=30)
assert r["cycle_samples"] == 1
def test_column_entry_times_ignores_comments():
models.log_event("a/b", 1, "moved", to_column="todo",
created_at=_days_ago(3))
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
times = models.column_entry_times("a/b")
assert times[1] == _days_ago(3)
# ── Panneau détail ─────────────────────────────────────────
def test_issue_detail_returns_body_and_comments(client):
r = client.get("/api/issues/Projets/demo/1")
assert r.status_code == 200
data = r.json()
assert data["issue"]["title"] == "Tâche un"
assert "markdown" in data["issue"]["body"]
assert data["comments"][0]["body"] == "Premier commentaire"
assert data["comments"][0]["user"]["login"] == "bruno"
def test_issue_detail_404(client):
r = client.get("/api/issues/Projets/demo/999")
assert r.status_code == 404
def test_post_comment_goes_to_gitea_and_journals(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/comments",
data={"body": "Bien vu !", "csrf_token": token},
)
assert r.status_code == 201
assert fake_gitea.posted_comments == [("Projets", "demo", 1, "Bien vu !")]
assert models.get_events("Projets/demo")[0]["event_type"] == "commented"
def test_post_comment_requires_csrf_and_body(client):
r = client.post("/api/issues/Projets/demo/1/comments", data={"body": "x"})
assert r.status_code == 403
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/comments",
data={"body": " ", "csrf_token": token},
)
assert r.status_code == 400
def test_board_contains_detail_drawer(client):
r = client.get("/board/Projets/demo")
assert 'id="card-detail"' in r.text
assert "marked.min.js" in r.text and "purify.min.js" in r.text
# ── Vieillissement + WIP ───────────────────────────────────
def test_aging_badge_after_threshold(client):
models.move_issue("Projets/demo", 1, "todo")
models.log_event("Projets/demo", 1, "moved", to_column="todo",
created_at=_days_ago(12))
r = client.get("/board/Projets/demo")
assert "age-badge" in r.text
assert "12 j" in r.text
def test_no_aging_badge_for_fresh_card(client):
# Every open card entered its column recently: no badge anywhere.
# (A card with no journal entry falls back to its Gitea creation
# date — the fake issues are old, so both cards get fresh entries.)
models.move_issue("Projets/demo", 1, "todo")
models.log_event("Projets/demo", 1, "moved", to_column="todo",
created_at=_days_ago(1))
models.log_event("Projets/demo", 5, "created", to_column="backlog",
created_at=_days_ago(1))
r = client.get("/board/Projets/demo")
assert "age-badge" not in r.text
def test_wip_limit_marks_column_over(client, monkeypatch):
monkeypatch.setenv("KANBANTE_WIP_LIMITS", "in_progress:1")
models.move_issue("Projets/demo", 1, "in_progress")
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/board/Projets/demo")
assert 'data-limit="1"' in r.text
assert "2/1" in r.text
assert "over" in r.text
+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
+240
View File
@@ -0,0 +1,240 @@
"""Palier 3 (0.5.0): search + my issues, briefing/ntfy, MCP, labels UI API."""
import json
from app import models
from tests.conftest import csrf_from
# ── Point 7 : recherche globale + mes issues ───────────────
def test_search_page_without_query(client):
r = client.get("/search")
assert r.status_code == 200
assert "search-hero" in r.text
def test_search_finds_issues_across_repos(client):
r = client.get("/search?q=focus")
assert r.status_code == 200
assert "Tâche focus" in r.text
assert "Tâche un" not in r.text
assert "Projets/demo" in r.text
def test_search_includes_closed_with_done_chip(client):
r = client.get("/search?q=ancienne")
assert "Ancienne tâche" in r.text
assert "Terminé" in r.text # column chip for closed issues
def test_my_issues_lists_only_assigned_open(client):
r = client.get("/my")
assert r.status_code == 200
assert "bruno" in r.text
assert "Tâche un" in r.text # assigned to bruno, open
assert "Tâche focus" not in r.text # not assigned
# ── Point 8 : briefing ─────────────────────────────────────
def test_kv_store_roundtrip():
models.set_kv("k1", "v1")
assert models.get_kv("k1") == "v1"
models.set_kv("k1", "v2")
assert models.get_kv("k1") == "v2"
assert models.get_kv("absent") is None
def test_briefing_page_and_api(client):
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/briefing")
assert r.status_code == 200
assert "briefing-text" in r.text
r = client.get("/api/briefing")
data = r.json()
assert data["ok"] is True
assert data["briefing"]["focus_total"] >= 1
assert "Kanbante" in data["text"]
def test_briefing_send_requires_ntfy_config(client):
token = csrf_from(client)
r = client.post("/api/briefing/send", data={"csrf_token": token})
assert r.status_code == 400
def test_briefing_send_posts_to_ntfy(client, monkeypatch):
monkeypatch.setenv("KANBANTE_NTFY_URL", "https://ntfy.example")
monkeypatch.setenv("KANBANTE_NTFY_TOPIC", "kanbante-bruno")
captured = {}
async def fake_send(settings, text):
captured["topic"] = settings.ntfy_topic
captured["text"] = text
return True
monkeypatch.setattr("app.main.send_ntfy", fake_send)
token = csrf_from(client)
r = client.post("/api/briefing/send", data={"csrf_token": token})
assert r.status_code == 200
assert r.json()["sent"] is True
assert captured["topic"] == "kanbante-bruno"
assert "briefing" in captured["text"]
assert models.get_kv("briefing_last_sent") is not None
def test_briefing_format_is_plain_text():
from app.briefing import format_briefing_text
text = format_briefing_text({
"date": "11/10/2026",
"activity_24h": {"closed": 2, "created": 1, "moved": 5, "avg_cycle_days": 3.5},
"focus_total": 1,
"focus_groups": [{"full_name": "a/b", "count": 1, "titles": ["X"]}],
"aging": [{"repo": "a/b", "number": 4, "title": "Vieille",
"days": 12, "column_label": "À faire"}],
"milestones_due": [{"repo": "a/b", "title": "S1",
"due_on": "2026-10-15", "open": 3}],
})
assert "2 fermée(s)" in text
assert "Vieille" in text and "12 j" in text
assert "S1" in text
# ── Point 9 : MCP ──────────────────────────────────────────
def _rpc(client, method, params=None, rpc_id=1):
payload = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
if params is not None:
payload["params"] = params
return client.post("/mcp", json=payload)
def test_mcp_initialize_and_tools_list(client):
r = _rpc(client, "initialize", {"protocolVersion": "2024-11-05"})
result = r.json()["result"]
assert result["serverInfo"]["name"] == "kanbante"
assert result["protocolVersion"] == "2024-11-05"
r = _rpc(client, "tools/list")
names = [t["name"] for t in r.json()["result"]["tools"]]
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
]
def test_mcp_call_get_board_and_search(client):
r = _rpc(client, "tools/call",
{"name": "get_board", "arguments": {"owner": "Projets", "repo": "demo"}})
result = r.json()["result"]
assert result["isError"] is False
board = json.loads(result["content"][0]["text"])
assert any(c["title"] == "Tâche un" for c in board["backlog"])
r = _rpc(client, "tools/call",
{"name": "search_issues", "arguments": {"query": "focus"}})
found = json.loads(r.json()["result"]["content"][0]["text"])
assert [f["title"] for f in found] == ["Tâche focus"]
def test_mcp_call_metrics_and_activity(client):
models.log_event("Projets/demo", 1, "moved", to_column="todo")
r = _rpc(client, "tools/call", {"name": "get_activity", "arguments": {}})
events = json.loads(r.json()["result"]["content"][0]["text"])
assert events[0]["event_type"] == "moved"
r = _rpc(client, "tools/call", {"name": "get_metrics", "arguments": {}})
metrics = json.loads(r.json()["result"]["content"][0]["text"])
assert metrics["moved"] >= 1
def test_mcp_unknown_method_and_tool(client):
r = _rpc(client, "resources/list")
assert r.json()["error"]["code"] == -32601
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
result = r.json()["result"]
assert result["isError"] is True # read-only server: no such tool
def test_mcp_notification_gets_202(client):
r = client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
assert r.status_code == 202
def test_mcp_info_endpoint(client):
r = client.get("/mcp")
assert r.status_code == 200
assert r.json()["read_only"] is True
# ── Board : gestion des étiquettes ────────────────────────
def test_labels_api_create_list_delete(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/labels",
data={"name": "urgent", "color": "e11d48", "csrf_token": token},
)
assert r.status_code == 201
label = r.json()["label"]
assert label["name"] == "urgent"
r = client.get("/api/repos/Projets/demo/labels")
assert [l["name"] for l in r.json()["labels"]] == ["urgent"]
r = client.request(
"DELETE", f"/api/repos/Projets/demo/labels/{label['id']}",
headers={"X-CSRF-Token": token},
)
assert r.json()["ok"] is True
r = client.get("/api/repos/Projets/demo/labels")
assert r.json()["labels"] == []
def test_labels_api_validation_and_csrf(client):
r = client.post(
"/api/repos/Projets/demo/labels",
data={"name": "x", "color": "ffffff"},
)
assert r.status_code == 403 # no CSRF token
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/labels",
data={"name": "x", "color": "pashex", "csrf_token": token},
)
assert r.status_code == 400
def test_issue_label_toggle(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/labels",
data={"name": "revue", "color": "0ea5e9", "csrf_token": token},
)
label_id = r.json()["label"]["id"]
r = client.post(
"/api/issues/Projets/demo/5/labels",
data={"label_id": label_id, "action": "add", "csrf_token": token},
)
assert r.status_code == 200
assert [l["name"] for l in r.json()["labels"]] == ["revue"]
r = client.post(
"/api/issues/Projets/demo/5/labels",
data={"label_id": label_id, "action": "remove", "csrf_token": token},
)
assert r.json()["labels"] == []
r = client.post(
"/api/issues/Projets/demo/5/labels",
data={"label_id": label_id, "action": "explode", "csrf_token": token},
)
assert r.status_code == 400
def test_board_has_label_manager_and_new_notes_panel(client):
r = client.get("/board/Projets/demo")
assert 'id="labels-btn"' in r.text
assert 'id="label-modal"' in r.text
assert 'id="notes-panel"' in r.text
assert 'id="notes-resize"' in r.text
assert 'id="notes-toggle"' in r.text
assert 'id="notes-status"' in r.text