33 Commits
Author SHA1 Message Date
Spark 25040f974c chore(release): Kanbante 0.9.0 — board dynamique
CI / test (push) Successful in 1m42s
2026-10-11 12:59:54 -04:00
Spark 99764e71b2 test(0.9.0): /api/board concordant avec la page, broadcasts sur move/create/webhook — 6 tests, 127 au total 2026-10-11 12:59:54 -04:00
Spark 1088dd0b48 feat(live): board dynamique sans refresh (issue #15)
Diffusion SSE sur toutes les mutations (move, création, capture,
étiquettes) via _publish_board_changed; GET /api/board/{o}/{r}
(fragments _card.html rendus serveur, même pipeline que la page);
board-live.js: diff en place par colonne avec animation FLIP,
application différée pendant un drag, filtres/compteurs réappliqués,
repli par sondage 45 s sans webhooks. Carte extraite en partiel
_card.html partagé page/API.
2026-10-11 12:59:54 -04:00
Spark 1743c8c101 chore(release): Kanbante 0.8.0 — palette Ctrl+K et digest ObsiGate
CI / test (push) Successful in 1m43s
2026-10-11 12:46:54 -04:00
Spark 411c0d2934 test(0.8.0): palette (endpoint, markup) + digest (format, MCP mocké, envois) — 11 tests, 121 au total 2026-10-11 12:46:54 -04:00
Spark 6e71b87d09 feat(digest): digest hebdo vers ObsiGate via MCP, sur approbation (issue #13)
app/digest.py: format markdown (journal 7 j + focus), client MCP
(initialize/tools/list/tools/call, réponses JSON ou SSE), gabarit
d'arguments JSON configurable, webhook générique. Page /digest:
statut, sonde des outils, envoi en un clic. Planificateur: compose
+ notifie (kv digest_pending), ne livre JAMAIS seul.
2026-10-11 12:46:54 -04:00
Spark 0417d0593f feat(palette): palette de commandes Ctrl+K sur toutes les pages (issue #10)
GET /api/palette: projets (filtrage local) + recherche globale
d'issues annotées de leur colonne. Composant partagé _palette.html
+ palette.js (actions, projets, issues; ↑↓/Entrée/Échap; bouton
⌨️ dans la navigation). Aucune librairie ajoutée.
2026-10-11 12:46:54 -04:00
Spark 39b751df56 chore(release): Kanbante 0.7.0 — capture éclair
CI / test (push) Successful in 1m41s
2026-10-11 12:40:32 -04:00
Spark 1cc27bd4fb test(capture): API, parsing, ntfy, formulaire web (12 tests, 110 au total) 2026-10-11 12:40:32 -04:00
Spark 0c280b3c7e feat(capture): page /capture (formulaire, statut, exemples) + navigation 2026-10-11 12:40:32 -04:00
Spark 395ab6e14c feat(capture): capture éclair — API à jeton dédié + écoute ntfy (issue #12)
POST /api/capture (X-Capture-Token ou Bearer; 503 si non configuré;
exempté Basic Auth/CSRF car machine-à-machine), JSON ou texte libre
(1re ligne = titre), dépôt par défaut KANBANTE_CAPTURE_REPO.
Module app/capture.py: parsing partagé + abonné ntfy en tâche de
fond (reconnexion, curseur kv_store anti-rejeu, tag 'kanban'...
'kanbante' ignoré). _do_capture commun: issue créée, carte placée,
événement 'created' source 'capture'.
2026-10-11 12:40:31 -04:00
Spark 05b8c506e0 chore(release): Kanbante 0.6.0 — colonne QA, réconciliation par étiquettes, Wallboard
CI / test (push) Successful in 1m42s
2026-10-11 12:26:12 -04:00
Spark 943ecd6a6b test(0.6.0): colonne QA, adoption d'étiquettes, wallboard (10 tests, 98 au total) 2026-10-11 12:26:12 -04:00
Spark af59417d07 feat(wall): Wallboard plein écran /wall — issue #14 de la feuille de route
Rotation automatique entre projets actifs (30 s), compteurs des
six colonnes, cartes en vol avec âge, fermées du jour (journal),
horloge; rafraîchissement en direct par le flux SSE (rechargement,
aucune interaction à protéger) + filet de sécurité à 5 min.
?repo= fige un projet. Lien de navigation ajouté partout.
2026-10-11 12:26:12 -04:00
Spark a358c45d73 feat(colonnes): colonne QA entre En cours et Révision + réconciliation par étiquettes
- KANBAN_COLUMNS gagne 'qa' (🧪, #06b6d4, étiquette kanban:qa);
  Focus inclut QA; dashboard et board affichent les 6 colonnes.
- _adopt_label_columns: une étiquette kanban:* unique qui diverge
  de l'état local fait adopter sa colonne (board + focus), avec
  événement journalisé source 'gitea'. C'est ce qui permet de
  piloter le board depuis Gitea même.
2026-10-11 12:26:12 -04:00
Spark b7ddc10971 chore(release): Kanbante 0.5.2 — correctif indice de colonne vide
CI / test (push) Successful in 1m41s
2026-10-11 12:08:28 -04:00
Spark 542ed70c1a fix(board): l'indice « Glissez une carte ici » suit enfin le contenu des colonnes
Rendu par le serveur pour les colonnes vides au chargement, il
n'était jamais retiré après un dépôt (signalé par Bruno). Nouvelle
fonction refreshEmptyHints() appelée après chaque déplacement et
après chaque retour arrière: indice retiré si la colonne contient
une carte, rétabli si elle devient vide. SortableJS restreint aux
cartes (draggable: '.kanban-card'). Logique vérifiée par mini-test
DOM (4 scénarios) en plus de node --check.
2026-10-11 12:08:28 -04:00
Spark 52c41ead91 chore(release): Kanbante 0.5.1 — correctif boards vides contre vrai Gitea
CI / test (push) Successful in 1m41s
2026-10-11 11:42:23 -04:00
Spark f40d4c817b test(régression): le fake reproduit le payload Gitea réel (pull_request: null)
Le faux omettait la clé pull_request sur les issues, ce qui a
masqué le bug de la 0.5.1 pendant que 84 tests restaient verts.
Clé désormais toujours présente (null = issue, objet = PR) +
4 tests dédiés dans test_regression_gitea_payload.py.
2026-10-11 11:42:23 -04:00
Spark 71881514c9 fix(board): les issues réelles Gitea n'étaient pas des PR — boards vides
Gitea 1.27.3 renvoie "pull_request": null sur les issues ordinaires:
_is_issue testait la présence de la clé et classait chaque vraie
issue comme un pull request. Board, Focus et briefing affichaient
zéro carte contre un vrai Gitea (le dashboard, basé sur les
compteurs de dépôts, semblait normal, d'où le signalement
« la nouvelle issue n'apparaît pas »). Le filtre teste maintenant
la valeur. Vérifié en réel: board bruno/flowdeck 0 -> 6 cartes.
2026-10-11 11:42:23 -04:00
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
44 changed files with 6563 additions and 292 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
+248
View File
@@ -1,5 +1,253 @@
# Changelog
## 0.9.0 — 2026-10-11
### Nouveautés
- **Board dynamique sans refresh** (issue #15) : le board reflète
l'état réel en continu. Côté serveur, chaque mutation connue
(déplacement, création, capture, bascule d'étiquette) est
désormais diffusée sur le hub SSE — jusqu'ici seuls les webhooks
émettaient — et les webhooks bénéficient du même canal pour
toutes leurs actions (étiquettes, créations, éditions comprises).
Nouvel endpoint `GET /api/board/{owner}/{repo}` : état du board
en JSON avec les cartes **rendues côté serveur** (partiel
`_card.html` partagé avec la page), même pipeline de
réconciliation/adoption que le board HTML. Côté client,
`board-live.js` applique le **diff en place** (seules les
colonnes changées sont remplacées, signature par colonne),
anime les cartes survivantes vers leur nouvelle place (FLIP,
désactivé si `prefers-reduced-motion`), diffère l'application
pendant un drag local, réapplique filtres/compteurs/indices, et
**sonde toutes les 45 s** en repli (installations sans webhooks,
onglet visible). Ni le défilement, ni le panneau détail, ni
Sortable ne sont affectés. Focus et Wallboard conservent leur
recharge douce, désormais déclenchée par toutes les mutations.
6 tests ajoutés (127 au total).
## 0.8.0 — 2026-10-11
### Nouveautés
- **Palette de commandes** (issue #10) : `Ctrl+K` / `Cmd+K` (ou le
bouton ⌨️ dans la navigation) ouvre sur toutes les pages une
palette façon Linear/Raycast — actions de navigation, tous les
projets (filtrage local instantané) et recherche globale d'issues
annotées de leur colonne (nouvel endpoint `GET /api/palette?q=`).
Navigation entièrement au clavier (↑↓, Entrée, Échap).
- **Digest hebdo vers ObsiGate** (issue #13) : le résumé markdown
de la semaine (issues fermées enrichies de leurs titres, cartes en
cours/QA/révision, compteurs du journal) est composé
automatiquement le jour et l'heure choisis
(`KANBANTE_DIGEST_WEEKDAY` / `KANBANTE_DIGEST_TIME`) avec
notification ntfy — mais **jamais livré sans approbation** :
l'envoi se fait d'un clic sur `/digest`, vers le serveur MCP
d'ObsiGate (client MCP intégré : initialize + tools/call ; URL,
jeton, nom d'outil et gabarit JSON d'arguments configurables ;
bouton « Tester la connexion » qui liste les outils du serveur)
ou vers un webhook générique (`KANBANTE_DIGEST_WEBHOOK_URL`,
JSON `{title, markdown, week}`).
11 tests ajoutés (121 au total).
## 0.7.0 — 2026-10-11
### Nouveauté
- **Capture éclair** (issue #12 de la feuille de route) : trois
entrées, un seul geste — transformer un message en issue placée
sur le board (événement `created`, source `capture`) :
- `POST /api/capture` : endpoint machine-à-machine protégé par un
**jeton de capture dédié** (`KANBANTE_CAPTURE_TOKEN`, en-tête
`X-Capture-Token` ou Bearer ; distinct du jeton Gitea, révocable
seul ; désactivé en 503 tant qu'il n'est pas défini ; exempté
d'auth Basic et de CSRF). JSON `{title, body, repo, column}` ou
texte libre (première ligne = titre). Dépôt par défaut :
`KANBANTE_CAPTURE_REPO`.
- **Écoute ntfy** : si `KANBANTE_CAPTURE_NTFY_TOPIC` est défini,
un abonné en tâche de fond (flux JSON du topic, reconnexion avec
recul, curseur persisté dans `kv_store` — aucun rejeu au
redémarrage) transforme chaque message en issue. Les messages
tagués `kanbante` sont ignorés (pas d'auto-ingestion).
- Page **`/capture`** : formulaire de saisie rapide, statut de
configuration et exemples prêts à copier (curl, ntfy). Lien
« ⚡ Capture » dans la navigation de toutes les pages.
12 tests ajoutés (110 au total).
## 0.6.0 — 2026-10-11
### Nouveautés
- **Colonne QA** entre « En cours » et « Révision » (🧪, étiquette
`kanban:qa`) : une étape de validation finale avant la révision.
Le flux complet devient Backlog → À faire → En cours → QA →
Révision → Terminé ; la vue Focus inclut les cartes en QA.
- **Wallboard** (issue #14 de la feuille de route) : `GET /wall`,
affichage plein écran pensé pour un écran d'atelier — rotation
automatique (30 s) entre les projets actifs, compteurs des six
colonnes, cartes en vol (En cours / QA / Révision / À faire) avec
âge, fermées du jour, horloge ; rafraîchissement en direct via le
flux SSE. `?repo=Proprietaire/depot` fige un seul projet. Lien
« 🖥️ Wallboard » ajouté à la navigation de toutes les pages.
- **Réconciliation par étiquettes** : si l'étiquette `kanban:*` d'une
issue ouverte diverge de sa colonne enregistrée (modifiée dans
Gitea ou par un autre outil), le board et la vue Focus adoptent la
colonne de l'étiquette et journalisent le déplacement (source
`gitea`). Règles : exactement une étiquette `kanban:*` fait foi ;
zéro ou plusieurs = l'état local gagne ; inactif si
`KANBANTE_SYNC_LABELS=0`. Déplacer une carte depuis Gitea devient
donc possible.
10 tests ajoutés (98 au total).
## 0.5.2 — 2026-10-11
### Correctif
- **L'indice « Glissez une carte ici » restait affiché après un
dépôt dans une colonne vide** : il est rendu par le serveur pour
les colonnes vides au chargement, et le glisser-déposer ne le
retirait jamais du DOM. `refreshEmptyHints()` recalcule désormais
l'indice d'après le contenu réel de chaque colonne après tout
déplacement (et après un retour arrière en cas d'échec) : retiré
dès qu'une carte est présente, rétabli quand la dernière carte
part. SortableJS est en outre restreint aux seules cartes
(`draggable: '.kanban-card'`), l'indice n'est plus un élément
déplaçable parasite.
## 0.5.1 — 2026-10-11
### Correctif critique
- **Les boards étaient vides contre un vrai Gitea** : l'API Gitea
(1.27.3 vérifié) renvoie la clé `"pull_request": null` sur les
issues ordinaires ; le filtre `_is_issue` testait la *présence* de
la clé (`"pull_request" not in item`), donc chaque vraie issue
était classée « pull request » et écartée. Board, vue Focus et
briefing ne montraient aucune carte (le dashboard, qui lit les
compteurs des dépôts, semblait normal). Corrigé : le filtre teste
la *valeur* (`not item.get("pull_request")`).
- Le faux Gitea des tests omettait la clé, ce qui masquait le bug :
il reproduit désormais fidèlement le payload réel (clé toujours
présente, `null` sur les issues), plus 4 tests de régression dédiés
(88 tests au total). Smoke test réel : le board `bruno/flowdeck`
passe de 0 à 6 cartes, dont l'issue de test « test d'ajout ! ».
## 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
+91 -3
View File
@@ -5,11 +5,27 @@
## Features
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / inactifs / archivés — le vrai drapeau Gitea)
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → Révision → Terminé, ordre des cartes persisté
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → **QA** → Révision → Terminé, ordre des cartes persisté
- 🎯 **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
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
- ⚡ **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 QA / En révision, tous projets confondus (`/focus`)
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
- ⚡ **Capture éclair** — page `/capture` (formulaire), endpoint `POST /api/capture` protégé par un jeton dédié, et écoute ntfy : un message publié sur le topic de capture devient une issue dans le dépôt par défaut
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **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 +108,20 @@ 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 |
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
## Sécurité
@@ -133,9 +163,67 @@ kanbante/
|---------|-------|-------------|
| GET | `/` | Dashboard tous les projets |
| GET | `/board/{owner}/{repo}` | Kanban board |
| GET | `/focus` | En cours + En révision, tous projets actifs |
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
| 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
+117
View File
@@ -0,0 +1,117 @@
"""Quick capture (roadmap #12): turn an external message into an issue.
Two entry points share the same parsing:
- POST /api/capture (token-authenticated, in app.main) — curl, a
shortcut, any automation;
- an ntfy *listener*: messages published to a dedicated topic become
issues in the default capture repo. Capture from a phone is then
just "share to the ntfy app".
The listener is a background task with reconnect + dedupe: the last
processed ntfy message id is kept (by the caller, in kv_store) and
passed back as `since`, so a restart never re-ingests old messages.
Messages tagged "kanbante" are skipped — Kanbante never ingests its
own posts.
"""
import asyncio
import json
import logging
import httpx
from .config import get_settings
logger = logging.getLogger("kanbante.capture")
MAX_TITLE_CHARS = 255
def parse_capture_text(text: str) -> tuple[str, str]:
"""First non-empty line = title, the rest = body."""
lines = (text or "").strip().splitlines()
lines = [line.rstrip() for line in lines]
while lines and not lines[0].strip():
lines.pop(0)
if not lines:
return "", ""
title = lines[0].strip()[:MAX_TITLE_CHARS]
body = "\n".join(lines[1:]).strip()
return title, body
def parse_ntfy_message(msg: dict) -> tuple[str, str] | None:
"""Extract (title, body) from an ntfy message, or None to skip it."""
if msg.get("event") not in (None, "message"):
return None
if "kanbante" in (msg.get("tags") or []):
return None
title = str(msg.get("title") or "").strip()
message = str(msg.get("message") or "").strip()
if title and message:
return title[:MAX_TITLE_CHARS], message
if message:
return parse_capture_text(message)
if title:
return title[:MAX_TITLE_CHARS], ""
return None
async def ntfy_capture_loop(create_issue, get_last_id, set_last_id) -> None:
"""Subscribe to the capture topic forever (reconnect with backoff).
create_issue(title, body) is an async callback; get_last_id /
set_last_id persist the ntfy cursor. Returns quietly when the
listener is no longer configured.
"""
seen: set[str] = set()
while True:
settings = get_settings()
base = settings.ntfy_url.rstrip("/")
topic = settings.capture_ntfy_topic
if not base or not topic:
return
since = get_last_id() or "latest"
headers = {}
if settings.ntfy_token:
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"GET", f"{base}/{topic}/json?since={since}", headers=headers
) as response:
response.raise_for_status()
logger.info(
"ntfy capture listening on %s/%s (since=%s)",
base, topic, since,
)
async for line in response.aiter_lines():
if not line.strip():
continue
try:
msg = json.loads(line)
except ValueError:
continue
msg_id = str(msg.get("id") or "")
if msg_id:
if msg_id in seen:
continue
seen.add(msg_id)
if len(seen) > 500:
seen = set(list(seen)[-250:])
set_last_id(msg_id)
parsed = parse_ntfy_message(msg)
if parsed is None:
continue
title, body = parsed
try:
await create_issue(title, body)
except Exception:
logger.exception("capture failed for %r", title)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
"ntfy capture stream interrupted (%s); retrying in 10s", exc
)
await asyncio.sleep(10)
+101 -1
View File
@@ -17,6 +17,57 @@ 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.
KANBANTE_CAPTURE_REPO Default repo for quick capture
(POST /api/capture, the /capture page
and the ntfy capture listener), e.g.
"Projets/kanbante". Empty = captures
must name their repo explicitly.
KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture
(header X-Capture-Token or Bearer).
Empty (default) = the endpoint is
disabled (503). Distinct from the
Gitea token; revocable on its own.
KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures:
each message published there becomes
an issue in KANBANTE_CAPTURE_REPO.
Uses KANBANTE_NTFY_URL/_TOKEN as the
server. Empty (default) = no listener.
KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is
composed (and ntfy-notified) on
KANBANTE_DIGEST_WEEKDAY. The digest is
NEVER auto-delivered: it waits for a
one-click approval on /digest.
KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0).
KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest
as JSON {title, markdown, week}.
KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the
digest is delivered by calling a
note-creation tool on it (client MCP).
KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP.
KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call
(see /digest for the live tool list).
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
"{title}" and "{content}" placeholders
are substituted (JSON-escaped).
"""
import os
@@ -25,7 +76,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.2.0"
APP_VERSION = "0.9.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -51,6 +102,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 +124,24 @@ 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 = ""
capture_repo: str = ""
capture_token: str = ""
capture_ntfy_topic: str = ""
digest_time: str = ""
digest_weekday: int = 0
digest_webhook_url: str = ""
obsigate_mcp_url: str = ""
obsigate_mcp_token: str = ""
obsigate_mcp_tool: str = ""
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
@property
def auth_enabled(self) -> bool:
@@ -84,4 +163,25 @@ 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", ""),
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""),
digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0),
digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""),
obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""),
obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""),
obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""),
obsigate_mcp_args=os.getenv(
"KANBANTE_OBSIGATE_MCP_ARGS",
'{"title": "{title}", "content": "{content}"}',
),
)
+188
View File
@@ -0,0 +1,188 @@
"""Weekly digest (roadmap #13): the week in Kanbante, as a note.
Composes a markdown digest from the event journal (last 7 days) and
the current focus snapshot, then — ONLY after a human approves on
/digest (standing rule: cross-app writes need approval) — delivers
it to ObsiGate through its MCP server (Kanbante as MCP *client*:
initialize → tools/call) or to a generic JSON webhook.
The ObsiGate tool contract is configurable (URL, tool name, JSON
argument template with {title}/{content} placeholders) because the
exact tool names of the target server are an operator decision, not
something to hardcode blindly.
"""
import json
import logging
import httpx
logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05"
def format_digest_markdown(
week_label: str,
closed_items: list[dict],
created_count: int,
moved_count: int,
focus_groups: list[dict],
) -> str:
"""Render the weekly digest as markdown (Obsidian-friendly)."""
lines = [f"# 🧠 Kanbante — semaine du {week_label}", ""]
lines.append(f"## ✅ Livré ({len(closed_items)})")
if closed_items:
for item in closed_items:
title = item.get("title") or ""
lines.append(
f"- [{item['repo']}#{item['number']}]({item.get('html_url', '')})"
f" {title}".rstrip()
)
else:
lines.append("- Rien n'a été fermé cette semaine.")
lines.append("")
lines.append("## 🚧 En cours")
total_focus = sum(len(g["cards"]) for g in focus_groups)
if total_focus:
for group in focus_groups:
lines.append(f"### {group['full_name']}")
for card in group["cards"]:
issue = card["issue"]
lines.append(
f"- #{issue['number']} {issue.get('title', '')}"
f" — *{card['column']}*"
)
else:
lines.append("- Aucune carte en cours, en QA ou en révision.")
lines.append("")
lines.append("## 📈 Activité de la semaine")
lines.append(f"- Issues créées : {created_count}")
lines.append(f"- Déplacements de cartes : {moved_count}")
lines.append(f"- Issues fermées : {len(closed_items)}")
lines.append("")
lines.append("*Digest composé par Kanbante à partir de son journal d'événements.*")
return "\n".join(lines)
def render_mcp_args(template: str, title: str, content: str) -> dict:
"""Substitute {title}/{content} in a JSON template, safely."""
def esc(value: str) -> str:
return json.dumps(value, ensure_ascii=False)[1:-1]
rendered = template.replace("{title}", esc(title)).replace(
"{content}", esc(content)
)
try:
args = json.loads(rendered)
except ValueError as exc:
raise ValueError(f"KANBANTE_OBSIGATE_MCP_ARGS is not valid JSON: {exc}")
if not isinstance(args, dict):
raise ValueError("KANBANTE_OBSIGATE_MCP_ARGS must be a JSON object")
return args
async def send_webhook(url: str, title: str, markdown: str, week: str) -> tuple[bool, str]:
"""POST the digest as JSON to a generic webhook."""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
url, json={"title": title, "markdown": markdown, "week": week}
)
if response.status_code < 300:
return True, f"webhook HTTP {response.status_code}"
return False, f"webhook HTTP {response.status_code}: {response.text[:200]}"
except Exception as exc:
return False, f"webhook error: {exc}"
def _mcp_headers(token: str) -> dict:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _parse_mcp_body(response: httpx.Response) -> dict:
"""MCP streamable HTTP answers JSON or an SSE frame — accept both."""
text = response.text.strip()
if not text:
return {}
if text.startswith("{"):
return json.loads(text)
payload: dict = {}
for line in text.splitlines():
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk.startswith("{"):
payload = json.loads(chunk)
return payload
async def _mcp_rpc(client: httpx.AsyncClient, url: str, token: str,
method: str, params: dict, req_id: int) -> dict:
response = await client.post(
url,
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
headers=_mcp_headers(token),
)
response.raise_for_status()
payload = _parse_mcp_body(response)
if "error" in payload:
err = payload["error"] or {}
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
return payload.get("result") or {}
async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
await _mcp_rpc(
client, url, token, "initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.9.0"},
},
req_id=1,
)
await client.post(
url,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=_mcp_headers(token),
)
async def mcp_list_tools(url: str, token: str) -> tuple[bool, list[str] | str]:
"""(True, [tool names]) or (False, error message)."""
try:
async with httpx.AsyncClient(timeout=20) as client:
await _mcp_session(client, url, token)
result = await _mcp_rpc(client, url, token, "tools/list", {}, req_id=2)
return True, [t.get("name", "?") for t in result.get("tools", [])]
except Exception as exc:
return False, f"MCP tools/list failed: {exc}"
async def mcp_call_tool(
url: str, token: str, tool: str, arguments: dict
) -> tuple[bool, str]:
"""Call one tool; (True, short result) or (False, error message)."""
try:
async with httpx.AsyncClient(timeout=30) as client:
await _mcp_session(client, url, token)
result = await _mcp_rpc(
client, url, token, "tools/call",
{"name": tool, "arguments": arguments}, req_id=3,
)
if result.get("isError"):
return False, f"tool {tool} reported an error: {str(result)[:300]}"
texts = [
block.get("text", "")
for block in result.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
]
return True, (" ".join(texts)[:300] or f"tool {tool} OK")
except Exception as exc:
return False, f"MCP tools/call failed: {exc}"
+121 -2
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:
@@ -168,8 +172,10 @@ class GiteaClient:
) -> list[dict]:
"""Get issues for a repo (paginated, cached).
Note: Gitea's issues endpoint also returns pull requests; each
PR carries a "pull_request" key and callers filter on it.
Note: Gitea's issues endpoint also returns pull requests; a
PR carries a non-null "pull_request" object, a plain issue
carries "pull_request": null — callers filter on truthiness
(see app.main._is_issue).
"""
key = f"issues:{owner}/{repo}:{state}"
cached = self._cache_get(key)
@@ -198,6 +204,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:
+1623 -13
View File
File diff suppressed because it is too large Load Diff
+174 -2
View File
@@ -16,7 +16,7 @@ import sqlite3
from contextlib import contextmanager
from typing import Iterator
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "qa", "review", "done"]
def db_path() -> str:
@@ -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(
+33
View File
@@ -0,0 +1,33 @@
<div class="kanban-card"
data-issue="{{ issue.number }}"
data-repo="{{ full_name }}"
data-title="{{ issue.title|lower }}"
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
<div class="card-id">
#{{ issue.number }}
{% if issue.html_url %}
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ issue.title }}</div>
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<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>
+12
View File
@@ -0,0 +1,12 @@
<div class="palette-overlay" id="palette-overlay" hidden>
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
<div class="palette-input-row">
<span class="palette-icon">⌨️</span>
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
placeholder="Chercher un projet, une issue, une action…">
<kbd>Échap</kbd>
</div>
<ul class="palette-results" id="palette-results"></ul>
<div class="palette-hint">↑↓ naviguer · ↵ ouvrir · Échap fermer · Ctrl+K pour rouvrir</div>
</div>
</div>
+67
View File
@@ -0,0 +1,67 @@
<!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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</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>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+123 -61
View File
@@ -6,34 +6,67 @@
<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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% 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>
@@ -43,77 +76,106 @@
<main class="board-container">
<div class="kanban-board">
{% for col in ['backlog','todo','in_progress','review','done'] %}
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
<div class="kanban-column"
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 }}"
data-repo="{{ full_name }}"
data-title="{{ issue.title|lower }}"
data-labels="{{ issue.labels|map(attribute='name')|join(',')|lower }}">
<div class="card-id">
#{{ issue.number }}
{% if issue.html_url %}
<a href="{{ issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ issue.title }}</div>
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
{% endif %}
<span class="card-date">{{ issue.created_at[:10] }}</span>
</div>
</div>
{% include "_card.html" %}
{% endfor %}
</div>
</div>
{% 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/board-live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<!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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</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>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
<!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>Capture éclair — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<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="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>⚡ Capture éclair</h1>
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
</section>
{% if created %}
<div class="briefing-card">
<h2>✅ Capturé !</h2>
<p>Issue <strong>#{{ created.number }}</strong> créée dans
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
· <a href="/board/{{ created.repo_full_name }}">ouvrir le board</a></p>
</div>
{% endif %}
{% if error %}
<div class="briefing-card">
<h2>❌ Capture impossible</h2>
<p>{{ error }}</p>
</div>
{% endif %}
<div class="briefing-card">
<h2>✍️ Saisie rapide</h2>
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
</label>
<label>Titre de l'issue
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
</label>
<label>Détails (optionnel)
<textarea name="body" rows="4" placeholder="Contexte, lien, capture d'écran à coller dans Gitea ensuite…"></textarea>
</label>
<label>Colonne
<select name="column">
<option value="backlog" selected>📥 Backlog</option>
<option value="todo">📋 À faire</option>
</select>
</label>
<button type="submit" class="btn-save">⚡ Capturer</button>
</form>
</div>
<div class="briefing-card">
<h2>🔌 Depuis un script ou un raccourci</h2>
{% if api_enabled %}
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
-H "X-Capture-Token: VOTRE_JETON" \
-H "Content-Type: application/json" \
-d '{"text": "Idée : trier les cartes par échéance\n\nDétails ici…"}'</pre>
<p>Texte libre accepté aussi (<code>Content-Type: text/plain</code>) : la première ligne devient le titre. Le dépôt vient de <code>KANBANTE_CAPTURE_REPO</code> ou du champ <code>repo</code>.</p>
{% else %}
<p>L'endpoint <code>POST /api/capture</code> est <strong>inactif</strong> : définis <code>KANBANTE_CAPTURE_TOKEN</code> (un jeton dédié, distinct de celui de Gitea) puis redémarre Kanbante.</p>
{% endif %}
</div>
<div class="briefing-card">
<h2>📱 Depuis le téléphone (ntfy)</h2>
{% if ntfy_topic %}
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
<p>Les messages portant le tag <code>kanbante</code> sont ignorés (jamais d'auto-ingestion).</p>
{% else %}
<p>Inactif : définis <code>KANBANTE_CAPTURE_NTFY_TOPIC</code> (et <code>KANBANTE_CAPTURE_REPO</code>) puis redémarre Kanbante. Chaque message publié sur ce topic deviendra une issue — capture depuis l'application ntfy en deux touchers.</p>
{% endif %}
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+61 -9
View File
@@ -9,15 +9,37 @@
<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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</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 +58,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,11 +75,20 @@
<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'] %}
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
@@ -72,19 +104,39 @@
<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>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!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>Digest hebdo — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<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="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>🧠 Digest hebdo → ObsiGate</h1>
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
</section>
{% if banner %}
<div class="briefing-card"><p>{{ banner }}</p></div>
{% endif %}
{% if tools is not none %}
<div class="briefing-card">
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
{% if tools %}
<ul>
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
</ul>
<p>Choisis l'outil de création de note, puis définis <code>KANBANTE_OBSIGATE_MCP_TOOL</code> (et au besoin <code>KANBANTE_OBSIGATE_MCP_ARGS</code>, gabarit JSON avec <code>{title}</code> et <code>{content}</code>).</p>
{% else %}
<p>Le serveur répond, mais n'expose aucun outil.</p>
{% endif %}
</div>
{% endif %}
<div class="briefing-card">
<h2>📤 Livraison</h2>
<p>
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
</p>
<form method="post" action="/digest/probe" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="obsigate">
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="webhook">
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
</form>
</div>
<div class="briefing-card">
<h2>👀 Aperçu du digest de la semaine</h2>
{% if pending %}
<pre class="capture-pre">{{ pending }}</pre>
{% else %}
<p>Aucun digest en attente. L'aperçu exact est composé au moment de l'envoi (boutons ci-dessus) à partir du journal des 7 derniers jours et de la vue Focus.</p>
{% endif %}
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></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">
+19 -3
View File
@@ -8,9 +8,22 @@
<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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -58,5 +71,8 @@
<footer>
Kanbante v{{ version }} · vue Focus
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.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>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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</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>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
<!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>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</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>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanbante — Wallboard</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="wall-body">
{% if slides %}
<div class="wall-stage" id="wall-stage">
{% for slide in slides %}
<section class="wall-slide{% if loop.first %} active{% endif %}">
<header class="wall-head">
<div>
<div class="wall-kicker">📂 Projet</div>
<h1 class="wall-project">{{ slide.full_name }}</h1>
</div>
<div class="wall-clock">
<div class="wall-time" data-clock>--:--</div>
<div class="wall-date" data-date></div>
{% if not single and slides|length > 1 %}
<div class="wall-rotation">{{ loop.index }} / {{ slides|length }}</div>
{% endif %}
</div>
</header>
<div class="wall-counts">
{% for col in columns %}
<div class="wall-count" style="--col: {{ column_colors[col] }}">
<span class="wall-count-n">{{ slide.counts[col] }}</span>
<span class="wall-count-l">{{ column_labels[col] }}</span>
</div>
{% endfor %}
</div>
{% if slide.cards %}
<ul class="wall-cards">
{% for card in slide.cards %}
<li class="wall-card">
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
{% if card.age_days is not none and card.age_days >= 1 %}
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
{% endif %}
<footer class="wall-foot">
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
<span class="wall-live">● EN DIRECT</span>
</footer>
</section>
{% endfor %}
</div>
{% else %}
<div class="wall-empty">
<h1>🖥️ Wallboard</h1>
<p>Aucun projet actif à afficher{% if single %} pour ce dépôt{% endif %}.</p>
<p class="wall-empty-hint">Astuce : <code>/wall?repo=Proprietaire/depot</code> fige un seul projet.</p>
</div>
{% endif %}
<script src="/static/js/wall.js"></script>
</body>
</html>
+5 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.2.0"
version = "0.9.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 = ["."]
+823 -187
View File
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
/**
* Kanbante — dynamic board (issue #15).
*
* The board reflects reality without a manual refresh: any change
* (another session, Gitea via webhook/labels, capture) is pushed
* over SSE; the client then fetches server-rendered card fragments
* from /api/board/<repo> and swaps only the columns that changed,
* animating surviving cards to their new place (FLIP). A discreet
* 45 s poll covers installs without Gitea webhooks. Local drags are
* never interrupted: a refresh arriving mid-drag waits for its end.
* Filters, scroll, the detail drawer and Sortable all survive,
* because only column contents are replaced — never the page.
*/
(function () {
var match = window.location.pathname.match(/^\/board\/(.+)$/);
if (!match) return;
var repoPath = match[1].replace(/\/$/, '');
var milestone = new URLSearchParams(window.location.search).get('milestone') || '';
var apiUrl = '/api/board/' + repoPath + (milestone ? '?milestone=' + encodeURIComponent(milestone) : '');
var repoFullName = repoPath.split('/').map(decodeURIComponent).join('/');
var lastEventAt = Date.now();
var inFlight = false;
var queued = false;
var debounce = null;
var failedOnce = false;
function signature(html) {
var h = 0;
for (var i = 0; i < html.length; i++) {
h = ((h << 5) - h + html.charCodeAt(i)) | 0;
}
return html.length + ':' + h;
}
function captureRects() {
var rects = {};
document.querySelectorAll('.kanban-card').forEach(function (card) {
rects[card.getAttribute('data-issue')] = card.getBoundingClientRect();
});
return rects;
}
function playFlip(before) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
document.querySelectorAll('.kanban-card').forEach(function (card) {
var key = card.getAttribute('data-issue');
var old = before[key];
if (!old) {
card.animate(
[{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'none' }],
{ duration: 220, easing: 'ease-out' }
);
return;
}
var now = card.getBoundingClientRect();
var dx = old.left - now.left;
var dy = old.top - now.top;
if (dx || dy) {
card.animate(
[{ transform: 'translate(' + dx + 'px,' + dy + 'px)' }, { transform: 'none' }],
{ duration: 280, easing: 'ease-in-out' }
);
}
});
}
function showFallbackPill() {
if (document.getElementById('live-pill')) return;
var pill = document.createElement('button');
pill.id = 'live-pill';
pill.type = 'button';
pill.textContent = '🔄 Actualisation automatique en échec — cliquer pour recharger';
pill.addEventListener('click', function () { window.location.reload(); });
document.body.appendChild(pill);
}
function applyData(data) {
var before = captureRects();
var changed = false;
Object.keys(data.columns).forEach(function (col) {
var container = document.querySelector('.column-cards[data-column="' + col + '"]');
if (!container) return;
var payload = data.columns[col];
var sig = signature(payload.html);
if (container.getAttribute('data-sig') === sig) return;
container.innerHTML = payload.html;
container.setAttribute('data-sig', sig);
changed = true;
});
if (!changed) return;
playFlip(before);
if (window.kanbanteUpdateCounts) window.kanbanteUpdateCounts();
if (window.kanbanteRefreshHints) window.kanbanteRefreshHints();
if (window.kanbanteApplyFilters) window.kanbanteApplyFilters();
}
function refresh() {
if (window.__kanbanteDragging) {
setTimeout(refresh, 700); // retry once the local drag ends
return;
}
if (inFlight) { queued = true; return; }
inFlight = true;
fetch(apiUrl, { headers: { 'Accept': 'application/json' } })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) { failedOnce = false; applyData(data); })
.catch(function () { if (!failedOnce) { failedOnce = true; showFallbackPill(); } })
.finally(function () {
inFlight = false;
if (queued) { queued = false; refresh(); }
});
}
function scheduleRefresh() {
clearTimeout(debounce);
debounce = setTimeout(refresh, 400);
}
// 1. Push: SSE events about this repo (internal moves broadcast
// {"type": "board-changed"}; webhooks broadcast their payload).
if (window.EventSource) {
var source = new EventSource('/api/stream');
source.addEventListener('gitea', function (e) {
try {
var payload = JSON.parse(e.data);
if (payload.repo && payload.repo !== repoFullName) return;
} catch (err) { /* unparseable -> refresh anyway */ }
lastEventAt = Date.now();
scheduleRefresh();
});
source.addEventListener('error', function () { /* EventSource retries itself */ });
}
// 2. Fallback poll (no webhooks configured, or SSE missed
// something): only when visible and quiet for a full cycle.
setInterval(function () {
if (document.visibilityState === 'visible' && Date.now() - lastEventAt >= 45000) {
lastEventAt = Date.now();
refresh();
}
}, 5000);
// 3. Coming back to the tab: catch up immediately.
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') refresh();
});
})();
+387 -1
View File
@@ -28,10 +28,14 @@ document.addEventListener('DOMContentLoaded', () => {
new Sortable(col, {
group: 'kanban',
animation: 200,
draggable: '.kanban-card',
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;
refreshEmptyHints();
const card = evt.item;
const formData = new FormData();
formData.append('repo_full_name', card.dataset.repo);
@@ -50,6 +54,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';
@@ -62,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
.catch(err => {
console.error('Move failed:', err);
revert(evt);
refreshEmptyHints();
showToast('❌ Échec du déplacement — carte replacée', true);
});
}
@@ -108,14 +118,390 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// The "Glissez une carte ici" hint is rendered by the server only
// for columns empty at page load. After a drag, the DOM is the
// truth: remove the hint from columns that hold cards, restore it
// on columns that became empty. (Bug reported 2026-10-11: the
// hint stayed forever after dropping a card into an empty column.)
function refreshEmptyHints() {
document.querySelectorAll('.column-cards').forEach(list => {
const hint = list.querySelector('.column-empty');
const hasCards = !!list.querySelector('.kanban-card');
if (hasCards && hint) hint.remove();
if (!hasCards && !hint) {
const p = document.createElement('p');
p.className = 'column-empty';
p.textContent = 'Glissez une carte ici';
list.appendChild(p);
}
});
}
function updateCounts() {
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;
}
});
}
// Exposed for board-live.js (issue #15): after a remote refresh
// swaps column fragments, filters/counts/hints must be recomputed.
window.kanbanteApplyFilters = applyFilters;
window.kanbanteRefreshHints = refreshEmptyHints;
window.kanbanteUpdateCounts = updateCounts;
// 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);
})();
+178
View File
@@ -0,0 +1,178 @@
/**
* Kanbante — Command palette (Ctrl+K), roadmap #10.
*
* One overlay for the whole app: static actions, every project, and
* global issue search (via /api/palette). Fully keyboard driven.
*/
(function () {
var overlay = document.getElementById('palette-overlay');
if (!overlay) return;
var input = document.getElementById('palette-input');
var list = document.getElementById('palette-results');
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
];
var projects = null; // cached after first fetch
var issues = []; // latest search results for the query
var items = []; // rendered items
var activeIndex = 0;
var debounce = null;
var lastQuery = '';
function open() {
overlay.hidden = false;
document.body.style.overflow = 'hidden';
input.value = '';
issues = [];
lastQuery = '';
render('');
input.focus();
if (projects === null) {
fetch('/api/palette')
.then(function (r) { return r.json(); })
.then(function (data) {
projects = data.projects || [];
render(input.value.trim());
})
.catch(function () { projects = []; });
}
}
function close() {
overlay.hidden = true;
document.body.style.overflow = '';
}
function buildItems(query) {
var q = query.toLowerCase();
var out = [];
ACTIONS.forEach(function (a) {
if (!q || a.label.toLowerCase().includes(q)) {
out.push({ icon: a.icon, label: a.label, sub: 'Action', href: a.href });
}
});
(projects || []).forEach(function (p) {
if (out.length >= 14) return;
if (!q || p.full_name.toLowerCase().includes(q)) {
out.push({
icon: '📂', label: p.full_name, sub: 'Projet · ouvrir le board',
href: '/board/' + p.full_name,
});
}
});
issues.forEach(function (i) {
out.push({
icon: '🐛',
label: '#' + i.number + ' · ' + i.title,
sub: i.repo + ' · ' + i.column_label + (i.state === 'closed' ? ' · fermée' : ''),
href: '/board/' + i.repo,
});
});
return out.slice(0, 14);
}
function render(query) {
items = buildItems(query);
activeIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
list.innerHTML = '';
if (!items.length) {
var empty = document.createElement('li');
empty.className = 'palette-empty';
empty.textContent = 'Aucun résultat — essaie un nom de projet ou un mot du titre d\'une issue.';
list.appendChild(empty);
return;
}
items.forEach(function (item, idx) {
var li = document.createElement('li');
li.className = 'palette-item' + (idx === activeIndex ? ' active' : '');
var icon = document.createElement('span');
icon.className = 'palette-item-icon';
icon.textContent = item.icon;
var main = document.createElement('span');
main.className = 'palette-item-main';
main.textContent = item.label;
var sub = document.createElement('span');
sub.className = 'palette-item-sub';
sub.textContent = item.sub;
li.appendChild(icon);
li.appendChild(main);
li.appendChild(sub);
li.addEventListener('click', function () { go(item); });
li.addEventListener('mousemove', function () {
if (activeIndex !== idx) { activeIndex = idx; render(input.value.trim()); }
});
list.appendChild(li);
});
}
function go(item) {
if (item) window.location.href = item.href;
}
function searchIssues(query) {
if (query.length < 2) { issues = []; render(query); return; }
fetch('/api/palette?q=' + encodeURIComponent(query))
.then(function (r) { return r.json(); })
.then(function (data) {
if (input.value.trim() !== query) return; // stale response
issues = data.issues || [];
if (projects === null) projects = data.projects || [];
render(query);
})
.catch(function () { /* keep previous results */ });
}
input.addEventListener('input', function () {
var query = input.value.trim();
activeIndex = 0;
render(query);
if (query !== lastQuery) {
lastQuery = query;
clearTimeout(debounce);
debounce = setTimeout(function () { searchIssues(query); }, 180);
}
});
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
render(input.value.trim());
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
render(input.value.trim());
} else if (e.key === 'Enter') {
e.preventDefault();
go(items[activeIndex]);
}
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) close();
});
document.addEventListener('keydown', function (e) {
var combo = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k';
if (combo) {
e.preventDefault();
if (overlay.hidden) open(); else close();
} else if (e.key === 'Escape' && !overlay.hidden) {
close();
}
});
document.querySelectorAll('.palette-trigger').forEach(function (btn) {
btn.addEventListener('click', function () { open(); });
});
})();
+42
View File
@@ -0,0 +1,42 @@
/**
* Kanbante — Wallboard: slide rotation, clock, live refresh.
*
* The wall is a display, not a workspace: no interaction to protect,
* so a realtime event simply reloads the page (plus a slow safety
* reload in case the stream drops silently).
*/
(function () {
var slides = Array.from(document.querySelectorAll('.wall-slide'));
var ROTATE_MS = 30000;
var current = 0;
if (slides.length > 1) {
setInterval(function () {
slides[current].classList.remove('active');
current = (current + 1) % slides.length;
slides[current].classList.add('active');
}, ROTATE_MS);
}
function tickClock() {
var now = new Date();
var time = now.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });
var date = now.toLocaleDateString('fr-CA', {
weekday: 'long', day: 'numeric', month: 'long',
});
document.querySelectorAll('[data-clock]').forEach(function (el) { el.textContent = time; });
document.querySelectorAll('[data-date]').forEach(function (el) { el.textContent = date; });
}
tickClock();
setInterval(tickClock, 15000);
// Live: any board activity refreshes the wall.
try {
var source = new EventSource('/api/stream');
source.onmessage = function () { location.reload(); };
} catch (err) {
console.warn('Wallboard live stream unavailable:', err);
}
// Safety net.
setTimeout(function () { location.reload(); }, 5 * 60 * 1000);
})();
+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.
"""
+131 -4
View File
@@ -45,14 +45,20 @@ 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}",
# Faithful to real Gitea 1.27 payloads: the key is ALWAYS
# present — null for a plain issue, an object for a PR.
"pull_request": {"url": "http://gitea.test/pr"} if pr else None,
}
if pr:
issue["pull_request"] = {"url": "http://gitea.test/pr"}
return issue
@@ -74,8 +80,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 +110,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 +159,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 +255,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):
+154
View File
@@ -0,0 +1,154 @@
"""Capture éclair (feuille de route #12) : API à jeton, page web, ntfy."""
import pytest
from app import models
from app.capture import parse_capture_text, parse_ntfy_message
from tests.conftest import csrf_from
TOKEN = "jeton-de-capture-test"
@pytest.fixture
def capture_env(monkeypatch):
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
monkeypatch.setenv("KANBANTE_CAPTURE_REPO", "Projets/demo")
# ── Parsing ───────────────────────────────────────────────────────
def test_parse_capture_text_first_line_is_title():
title, body = parse_capture_text("Idée géniale\n\nDétails\nsur deux lignes")
assert title == "Idée géniale"
assert body == "Détails\nsur deux lignes"
def test_parse_capture_text_empty():
assert parse_capture_text(" \n ") == ("", "")
def test_parse_ntfy_message_variants():
assert parse_ntfy_message(
{"event": "message", "title": "Titre", "message": "Corps"}
) == ("Titre", "Corps")
assert parse_ntfy_message(
{"event": "message", "message": "Ligne un\n\nLigne deux"}
) == ("Ligne un", "Ligne deux")
assert parse_ntfy_message({"event": "open"}) is None
assert parse_ntfy_message(
{"event": "message", "message": "auto", "tags": ["kanbante"]}
) is None # never ingest our own posts
# ── API ───────────────────────────────────────────────────────────
def test_capture_api_disabled_without_token(client, fake_gitea):
r = client.post("/api/capture", json={"text": "Idée"})
assert r.status_code == 503
def test_capture_api_rejects_wrong_token(client, fake_gitea, capture_env):
r = client.post(
"/api/capture",
json={"text": "Idée"},
headers={"X-Capture-Token": "mauvais"},
)
assert r.status_code == 401
assert fake_gitea.created == []
def test_capture_api_creates_issue_in_default_repo(client, fake_gitea, capture_env):
r = client.post(
"/api/capture",
json={"text": "Idée éclair\n\nLe détail qui compte"},
headers={"X-Capture-Token": TOKEN},
)
assert r.status_code == 201
data = r.json()
assert data["ok"] is True
number = data["issue"]["number"]
assert fake_gitea.created == [("Projets", "demo", "Idée éclair")]
state = models.get_board_state("Projets/demo")
assert number in [c["issue_number"] for c in state["backlog"]]
events = [
e for e in models.get_events("Projets/demo")
if e["issue_number"] == number
]
assert any(
e["event_type"] == "created" and e["source"] == "capture" for e in events
)
def test_capture_api_text_plain_and_bearer(client, fake_gitea, capture_env):
r = client.post(
"/api/capture",
content="Titre en texte brut\nCorps",
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "text/plain",
},
)
assert r.status_code == 201
assert fake_gitea.created == [("Projets", "demo", "Titre en texte brut")]
def test_capture_api_explicit_repo_and_column(client, fake_gitea, capture_env):
r = client.post(
"/api/capture",
json={"title": "Direct en todo", "repo": "Projets/demo", "column": "todo"},
headers={"X-Capture-Token": TOKEN},
)
assert r.status_code == 201
number = r.json()["issue"]["number"]
state = models.get_board_state("Projets/demo")
assert number in [c["issue_number"] for c in state["todo"]]
def test_capture_api_validates_input(client, fake_gitea, capture_env):
r = client.post(
"/api/capture", json={"text": " "},
headers={"X-Capture-Token": TOKEN},
)
assert r.status_code == 400
r = client.post(
"/api/capture",
json={"title": "X", "repo": "pas-de-slash"},
headers={"X-Capture-Token": TOKEN},
)
assert r.status_code == 400
def test_capture_api_no_repo_anywhere(client, fake_gitea, monkeypatch):
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
monkeypatch.delenv("KANBANTE_CAPTURE_REPO", raising=False)
r = client.post(
"/api/capture", json={"title": "Orpheline"},
headers={"X-Capture-Token": TOKEN},
)
assert r.status_code == 400
# ── Page web ──────────────────────────────────────────────────────
def test_capture_page_renders(client, capture_env):
r = client.get("/capture")
assert r.status_code == 200
assert "Capture éclair" in r.text
assert "Projets/demo" in r.text
def test_capture_web_form_creates_issue(client, fake_gitea, capture_env):
token = csrf_from(client)
r = client.post(
"/capture",
data={
"repo_full_name": "Projets/demo", "title": "Via le formulaire",
"body": "Détails", "column": "backlog", "csrf_token": token,
},
)
assert r.status_code == 200
assert "Capturé" in r.text
assert fake_gitea.created == [("Projets", "demo", "Via le formulaire")]
+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
+146
View File
@@ -0,0 +1,146 @@
"""Palier 4 (0.6.0): QA column, label reconciliation, wallboard."""
from fastapi.testclient import TestClient
import app.main as main
from app import models
from tests.conftest import _issue, csrf_from
def _column_block(html: str, column: str) -> str:
blocks = html.split('<div class="kanban-column"')
for block in blocks[1:]:
if f'data-column="{column}"' in block.split(">", 1)[0]:
return block
raise AssertionError(f"column {column} not found")
def _labelled(number: int, title: str, label_names: list[str]) -> dict:
issue = _issue(number, title)
issue["labels"] = [
{"id": 900 + i, "name": name, "color": "8b5cf6"}
for i, name in enumerate(label_names)
]
return issue
# ── QA column ─────────────────────────────────────────────────────
def test_board_has_six_columns_including_qa(client):
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "🧪 QA" in r.text
positions = [
r.text.index(f'data-column="{col}"')
for col in ("backlog", "todo", "in_progress", "qa", "review", "done")
]
assert positions == sorted(positions) # QA between in_progress and review
def test_move_to_qa_syncs_kanban_qa_label(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"to_column": "qa", "from_column": "backlog",
"to_order": "1", "from_order": "5", "csrf_token": token,
},
)
assert r.status_code == 200
assert r.json()["label_sync"] == "ok"
assert ("add", 1, "kanban:qa") in fake_gitea.label_ops
state = models.get_board_state("Projets/demo")
assert 1 in [c["issue_number"] for c in state["qa"]]
def test_qa_cards_appear_in_focus(client, fake_gitea):
models.move_issue("Projets/demo", 5, "qa")
r = client.get("/focus")
assert r.status_code == 200
assert "Tâche focus" in r.text
assert "🧪 QA" in r.text
# ── Label reconciliation (adoption) ───────────────────────────────
def test_board_adopts_diverging_kanban_label(client, fake_gitea):
fake_gitea.issues["Projets/demo"].append(
_labelled(9, "Déplacée via Gitea", ["kanban:review"])
)
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "Déplacée via Gitea" in _column_block(r.text, "review")
state = models.get_board_state("Projets/demo")
assert 9 in [c["issue_number"] for c in state["review"]]
events = [
e for e in models.get_events("Projets/demo") if e["issue_number"] == 9
]
assert any(
e["event_type"] == "moved" and e["to_column"] == "review"
and e["source"] == "gitea"
for e in events
)
def test_board_ignores_conflicting_kanban_labels(client, fake_gitea):
fake_gitea.issues["Projets/demo"].append(
_labelled(9, "Étiquettes en conflit", ["kanban:todo", "kanban:review"])
)
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "Étiquettes en conflit" in _column_block(r.text, "backlog")
state = models.get_board_state("Projets/demo")
elsewhere = [
c["issue_number"]
for col, cards in state.items() if col != "backlog"
for c in cards
]
assert 9 not in elsewhere
def test_adoption_is_idempotent(client, fake_gitea):
fake_gitea.issues["Projets/demo"].append(
_labelled(9, "Stable", ["kanban:qa"])
)
client.get("/board/Projets/demo")
client.get("/board/Projets/demo")
events = [
e for e in models.get_events("Projets/demo")
if e["issue_number"] == 9 and e["event_type"] == "moved"
]
assert len(events) == 1 # adopted once, then label and state agree
# ── Wallboard ─────────────────────────────────────────────────────
def test_wall_renders_active_projects(client):
models.move_issue("Projets/demo", 5, "in_progress")
r = client.get("/wall")
assert r.status_code == 200
assert "Projets/demo" in r.text
assert "Tâche focus" in r.text # the in-progress card is on the wall
assert 'class="wall-slide active"' in r.text
assert "🧪 QA" in r.text # the six column counters include QA
def test_wall_single_repo_mode(client):
r = client.get("/wall", params={"repo": "Projets/demo"})
assert r.status_code == 200
assert "Projets/demo" in r.text
def test_wall_unknown_repo_shows_empty_state(client):
r = client.get("/wall", params={"repo": "Inconnu/xyz"})
assert r.status_code == 200
assert "Aucun projet actif" in r.text
def test_wall_is_behind_auth(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as secured:
assert secured.get("/wall").status_code == 401
+164
View File
@@ -0,0 +1,164 @@
"""Palier 5 (0.8.0): palette de commandes (#10) + digest hebdo (#13)."""
import httpx
import pytest
from app import digest as digest_service
from app import models
from tests.conftest import csrf_from
# ── Palette (#10) ─────────────────────────────────────────────────
def test_palette_endpoint_projects_and_issues(client, fake_gitea):
r = client.get("/api/palette", params={"q": "tâche"})
assert r.status_code == 200
data = r.json()
assert any(p["full_name"] == "Projets/demo" for p in data["projects"])
titles = [i["title"] for i in data["issues"]]
assert "Tâche un" in titles
found = next(i for i in data["issues"] if i["title"] == "Tâche un")
assert found["repo"] == "Projets/demo"
assert found["column"] == "backlog"
def test_palette_short_query_skips_issue_search(client):
r = client.get("/api/palette", params={"q": "x"})
assert r.status_code == 200
assert r.json()["issues"] == []
assert r.json()["projects"] # projects are always there
def test_palette_markup_on_pages(client):
for path in ("/", "/focus", "/capture"):
r = client.get(path)
assert r.status_code == 200
assert 'id="palette-overlay"' in r.text, path
assert "/static/js/palette.js" in r.text, path
assert "palette-trigger" in r.text, path
# ── Digest : composition (#13) ────────────────────────────────────
def test_digest_markdown_format():
md = digest_service.format_digest_markdown(
"2026-10-05 au 2026-10-11",
[{"repo": "Projets/demo", "number": 3, "title": "Ancienne tâche",
"html_url": "http://gitea.test/x"}],
created_count=2,
moved_count=5,
focus_groups=[{
"full_name": "Projets/demo",
"cards": [{"issue": {"number": 5, "title": "Tâche focus"},
"column": "🚧 En cours"}],
}],
)
assert "# 🧠 Kanbante — semaine du 2026-10-05" in md
assert "## ✅ Livré (1)" in md
assert "Projets/demo#3" in md
assert "Tâche focus" in md
assert "Déplacements de cartes : 5" in md
def test_render_mcp_args_escapes_content():
args = digest_service.render_mcp_args(
'{"title": "{title}", "content": "{content}", "folder": "Kanbante"}',
'Titre "cité"',
"Ligne 1\nLigne 2 avec \\ barre",
)
assert args["title"] == 'Titre "cité"'
assert args["content"] == "Ligne 1\nLigne 2 avec \\ barre"
assert args["folder"] == "Kanbante"
with pytest.raises(ValueError):
digest_service.render_mcp_args("{pas json", "t", "c")
def test_digest_page_renders(client):
r = client.get("/digest")
assert r.status_code == 200
assert "Digest hebdo" in r.text
assert "approbation" in r.text
# ── Digest : livraison ────────────────────────────────────────────
def _mcp_handler_factory(calls: list):
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
payload = _json.loads(request.content.decode() or "{}")
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1,
"result": {"protocolVersion": "2024-11-05", "capabilities": {}}})
if method == "notifications/initialized":
return httpx.Response(202, json={})
if method == "tools/list":
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 2,
"result": {"tools": [{"name": "create_note"}, {"name": "search"}]}})
if method == "tools/call":
calls.append(payload["params"])
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 3,
"result": {"content": [{"type": "text", "text": "note créée"}]}})
return httpx.Response(200, json={})
return handler
@pytest.fixture
def mcp_transport(monkeypatch):
calls: list = []
transport = httpx.MockTransport(_mcp_handler_factory(calls))
real_async_client = httpx.AsyncClient
def factory(*args, **kwargs):
kwargs["transport"] = transport
return real_async_client(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", factory)
return calls
async def test_mcp_list_tools(mcp_transport):
ok, tools = await digest_service.mcp_list_tools("http://obsigate.test/mcp", "")
assert ok is True
assert tools == ["create_note", "search"]
async def test_mcp_call_tool(mcp_transport):
ok, message = await digest_service.mcp_call_tool(
"http://obsigate.test/mcp", "", "create_note",
{"title": "T", "content": "C"},
)
assert ok is True
assert "note créée" in message
call = next(c for c in mcp_transport if isinstance(c, dict))
assert call["name"] == "create_note"
assert call["arguments"] == {"title": "T", "content": "C"}
def test_digest_send_to_obsigate(client, fake_gitea, monkeypatch, mcp_transport):
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_TOOL", "create_note")
token = csrf_from(client)
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
assert r.status_code == 200
assert "✅ Digest envoyé (obsigate)" in r.text
assert models.get_kv("digest_last_result")
def test_digest_send_requires_configuration(client, fake_gitea):
token = csrf_from(client)
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
assert r.status_code == 200
assert "❌ ObsiGate incomplet" in r.text
def test_digest_probe_lists_tools(client, fake_gitea, monkeypatch, mcp_transport):
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
token = csrf_from(client)
r = client.post("/digest/probe", data={"csrf_token": token})
assert r.status_code == 200
assert "create_note" in r.text
+112
View File
@@ -0,0 +1,112 @@
"""Palier 6 (0.9.0): board dynamique sans refresh (issue #15)."""
import re
import app.main as main
from tests.conftest import csrf_from
def _page_counts(html: str) -> dict[str, int]:
counts = {}
for col, count in re.findall(
r'data-column="(\w+)">\s*<div class="column-header".*?column-count[^>]*>(\d+)',
html, re.S,
):
counts[col] = int(count)
return counts
def test_api_board_matches_page(client, fake_gitea):
page = client.get("/board/Projets/demo")
assert page.status_code == 200
api = client.get("/api/board/Projets/demo")
assert api.status_code == 200
data = api.json()
assert data["repo"] == "Projets/demo"
page_counts = _page_counts(page.text)
for col in ("backlog", "todo", "in_progress", "qa", "review", "done"):
assert data["columns"][col]["count"] == page_counts[col], col
assert "Tâche un" in data["columns"]["backlog"]["html"]
assert 'data-issue="1"' in data["columns"]["backlog"]["html"]
# Empty column carries the same hint as the server-rendered page.
assert "Glissez une carte ici" in data["columns"]["qa"]["html"]
def test_api_board_reflects_move(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "1", "csrf_token": token,
})
assert r.status_code == 200 and r.json()["ok"]
data = client.get("/api/board/Projets/demo").json()
assert 'data-issue="1"' in data["columns"]["in_progress"]["html"]
# #5 ("Tâche focus", also unplaced) stays in the backlog.
assert data["columns"]["backlog"]["count"] == 1
assert 'data-issue="5"' in data["columns"]["backlog"]["html"]
def test_board_page_uses_board_live_not_live(client, fake_gitea):
page = client.get("/board/Projets/demo")
assert "/static/js/board-live.js" in page.text
assert "/static/js/live.js" not in page.text
focus = client.get("/focus")
assert "/static/js/live.js" in focus.text # unchanged elsewhere
def test_move_broadcasts_board_changed(client, fake_gitea, monkeypatch):
sent = []
async def record(payload):
sent.append(payload)
monkeypatch.setattr(main.hub, "broadcast", record)
token = csrf_from(client)
r = client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"from_column": "backlog", "to_column": "todo",
"to_order": "1", "csrf_token": token,
})
assert r.status_code == 200
assert any(
p.get("type") == "board-changed" and p.get("repo") == "Projets/demo"
for p in sent
), sent
def test_create_issue_broadcasts(client, fake_gitea, monkeypatch):
sent = []
async def record(payload):
sent.append(payload)
monkeypatch.setattr(main.hub, "broadcast", record)
token = csrf_from(client)
r = client.post("/api/issues", data={
"repo_full_name": "Projets/demo", "title": "Nouvelle carte live",
"column": "backlog", "csrf_token": token,
})
assert r.status_code == 201
assert any(p.get("type") == "board-changed" for p in sent), sent
def test_webhook_label_event_broadcasts(client, fake_gitea, monkeypatch):
sent = []
async def record(payload):
sent.append(payload)
monkeypatch.setattr(main.hub, "broadcast", record)
r = client.post(
"/webhooks/gitea",
json={
"action": "labeled",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
},
headers={"x-gitea-event": "issues"},
)
assert r.status_code == 200
assert r.json()["ok"] is True
assert sent and sent[0]["action"] == "labeled"
+34
View File
@@ -0,0 +1,34 @@
"""Regression: real Gitea returns "pull_request": null on plain issues.
The 0.2.0–0.5.0 filter tested key *presence* ("pull_request" not in
item), which classified every real issue as a PR and silently emptied
every board against a real Gitea — while the test fake omitted the
key entirely, so the suite stayed green. Found 2026-10-11 after a
user report ("new issue does not appear on the board"): the live
flowdeck board rendered 0 cards for 6 open issues.
"""
from app.main import _is_issue
def test_plain_issue_with_null_pull_request_is_an_issue():
assert _is_issue({"number": 1, "pull_request": None}) is True
def test_pull_request_object_is_not_an_issue():
assert _is_issue({"number": 2, "pull_request": {"url": "http://x/pr/2"}}) is False
def test_missing_key_is_an_issue():
assert _is_issue({"number": 3}) is True
def test_board_renders_cards_from_realistic_payload(client):
# The shared fake now mirrors the real payload (key always
# present, null on plain issues): if the filter regresses to a
# presence test, these cards vanish and this test goes red.
r = client.get("/board/Projets/demo")
assert r.status_code == 200
assert "Tâche un" in r.text
assert "Tâche focus" in r.text
assert "PR déguisée" not in r.text