85 Commits
Author SHA1 Message Date
Spark 9e2096c9b6 chore(release): Kanbante 0.22.0 — login, sidebar, chantier B
CI / test (push) Successful in 2m1s
2026-10-14 08:02:49 -04:00
Spark e63fe39578 test: palier 18 — login/session, sidebar, hydratation des instantanés 2026-10-14 08:02:49 -04:00
Spark b7d21f80df feat(perf): chantier B — instantanés SQLite et rafraîchisseur d'arrière-plan
- Table snapshots + écriture traversante du cache client,
  hydratation au démarrage (SWR : servi + rafraîchi en fond).
- Boucle de rafraîchissement (120 s par défaut) : index, étoiles,
  issues all+open et jalons des projets actifs ; webhook =
  rafraîchissement immédiat du dépôt ; fraîcheur affichée.
2026-10-14 08:02:49 -04:00
Spark c7711c0ca2 feat(sidebar): replier en haut à droite de la recherche, accordéon exclusif
- Bouton replier : icône seule dans l'en-tête, gabarit du bouton
  recherche ; le pied garde version + fraîcheur + Sortir.
- Une seule section ouverte à la fois (kanbante-nav-open),
  amorce serveur selon la page courante, plus de kanbante-nav-closed.
2026-10-14 08:02:49 -04:00
Spark 319619d200 feat(auth): écran de connexion + session signée (le Basic reste pour les scripts)
- /login professionnel, cookie HMAC 7 jours, next local seulement,
  CSRF double-submit réutilisé, /logout réel + lien Sortir.
- Middleware : session OU Basic ; navigateurs redirigés vers
  /login, API en 401 JSON ; /static et /login publics.
2026-10-14 08:02:49 -04:00
Spark 0bdde28b79 chore(release): Kanbante 0.21.0 — sélecteur de dépôt Capture
CI / test (push) Successful in 1m54s
2026-10-12 15:47:28 -04:00
Spark 4b38acdcba test: palier 17 — /api/repos exhaustif et sélecteur branché sur /capture 2026-10-12 15:47:28 -04:00
Spark 760fbd8c6d feat(capture): sélecteur de dépôt intelligent — suggestions, Tab par segment, validation
- GET /api/repos exhaustif (épinglés, récents, archivés),
  la palette garde son plafond de 60.
- repo-picker.js : liste filtrée sur les deux segments,
  complétion Tab façon shell (pr Tab → Projets/ avec le
  séparateur posé, kan Tab → kanbante), ↑/↓/Entrée/clic,
  avertissement doux si le dépôt n'existe pas.
2026-10-12 15:47:28 -04:00
Spark f5d2d7d716 chore(release): Kanbante 0.20.1 — correctifs tri et regroupement
CI / test (push) Successful in 1m50s
2026-10-12 14:54:24 -04:00
Spark 71e705ea64 fix(dashboard): tri par date des tableaux, tri/regroupement sur les Épinglés
- tablesort.js : parseFloat lisait l'année des dates ISO (2026)
  et mettait toutes les lignes à égalité — comparaison numérique
  seulement pour les valeurs entièrement numériques.
- dashboard.js : pinned-grid rejoint les grilles triées et
  regroupées (son exclusion rendait les deux outils invisibles
  là où l'œil se pose) ; dates comparées via Date.parse.
- data-first-dir=desc sur Dernière activité / Écart / Zombies.
2026-10-12 14:54:24 -04:00
Spark 7918b54943 chore(release): Kanbante 0.20.0 — en-tête Projets professionnel
CI / test (push) Successful in 2m13s
2026-10-12 14:40:05 -04:00
Spark 3695bfac8a test: palier 16 — en-tête professionnel (KPI, barre unique, crochets préservés) 2026-10-12 14:40:05 -04:00
Spark 57d93c4f4b feat(ui): en-tête Projets professionnel — titre sobre, bande KPI unifiée, barre d'outils unique
- Titre « Projets » + sous-titre discret (fini le Bonjour 👋),
  en-tête deux fois plus compact.
- Les 4 statistiques deviennent une bande KPI bordée unique
  (séparateurs fins, chiffres tabulaires, KPI journal cliquables
  vers /activity).
- Onglets/filtre/tri/regroupement/vue réunis dans UNE barre :
  contrôle segmenté avec pastilles de compte, filtre rectangle.
  Crochets JS/Alpine inchangés ; .stat-tile conservée (briefing).
2026-10-12 14:40:05 -04:00
Spark 900590efed chore(release): Kanbante 0.19.0 — performance et pilotage (A + C)
CI / test (push) Successful in 1m50s
2026-10-12 13:53:59 -04:00
Spark 0aa991047f test: palier 15 — tableaux pilotage, ancres, SWR (frais/périmé/mutation) 2026-10-12 13:53:59 -04:00
Spark 79122c20d0 feat(pilotage): Prévisions en tableau des jalons, Santé en tableau de sévérité (chantier C)
- /forecast : une ligne par jalon, tous projets, triée par
  risque (écart décroissant), filtrable, ancres ms-<owner>-<repo>
  pointées par le bloc jalon des cartes du dashboard ; débit
  par projet conservé en tableau compact.
- /health : tableau trié par sévérité (pastille, zombies,
  goulot) ; page détail : sélecteur de projet (health.js).
- Tri des tableaux mutualisé dans static/js/tablesort.js
  (dashboard, forecast, health).
2026-10-12 13:53:59 -04:00
Spark 3e3187d597 feat(perf): boucles par dépôt parallélisées et cache stale-while-revalidate (chantier A)
- _parallel_map : asyncio.gather borné (16) pour dashboard,
  /health, /forecast, /triage et le mur — le patron de /focus,
  généralisé. Avant : la page attendait la somme des allers-
  retours Gitea de tous les dépôts, en séquentiel.
- Client Gitea : les listes (dépôts, issues, jalons, étoiles)
  passent en stale-while-revalidate — une entrée périmée est
  servie immédiatement et rafraîchie en tâche de fond. Les
  mutations vident le cache et bumpent une génération qui
  désarme les rafraîchissements en vol.
- /forecast calcule des lignes de jalons tous projets (écart
  ETA/échéance) ; /health trie par sévérité et expose la
  pastille ; le détail santé reçoit la liste des projets.
2026-10-12 13:53:59 -04:00
Spark 593f076555 chore(release): Kanbante 0.18.0 — vague 2 suite : activité, actions rapides, tri, liste dense
CI / test (push) Successful in 1m47s
2026-10-11 23:18:01 -04:00
Spark 311a14090d test(0.18.0): activité, outils d'affichage, liste dense, actions rapides — 7 tests, 185 au total 2026-10-11 23:18:01 -04:00
Spark c67a14da65 feat(dashboard): ligne d'activité, menu ⋯, tri/regroupement, vue liste (#19, #21, #22, #23)
Menu d'actions rapides par carte; barre d'outils tri +
regroupement par propriétaire (sections repliables, préférences
localStorage); bascule Grille/Liste persistée avec tableaux
triables réutilisant jalon/santé des cartes; dashboard.js dédié.
2026-10-11 23:18:01 -04:00
Spark 7ec34245c5 feat(dashboard): activité 7 jours agrégée et capture pré-remplie (#19, #23)
get_project_activity(): fermetures/déplacements 7 j et dernier
événement par dépôt en une seule requête sur board_events;
la page /capture accepte ?repo= pour pré-remplir le dépôt.
2026-10-11 23:18:01 -04:00
Spark 018bde0fa5 chore(release): Kanbante 0.17.0 — vague 2 : cartes projets enrichies et page Projet
CI / test (push) Successful in 1m46s
2026-10-11 22:52:22 -04:00
Spark 0883191f57 test(0.17.0): jalon, santé, étoiles, page projet — 7 tests, 178 au total 2026-10-11 22:52:22 -04:00
Spark ba47b1a9ac feat(projets): page Projet /project/{owner}/{repo} (#25)
Accueil du projet assemblé des briques existantes: jalons
ouverts (progression, échéances, ETA), santé en résumé, débit,
activité récente du journal, notes; actions board/capture/
triage/santé/wallboard/Gitea. Le titre des cartes y pointe.
2026-10-11 22:52:22 -04:00
Spark cf58bb9d0c feat(projets): épinglés par étoiles Gitea (#20)
list_starred/set_starred sur /user/starred (vérité dans la
forge), endpoint POST /api/projects/{o}/{r}/star (CSRF), section
Épinglés hors onglets, bouton étoile sur les cartes et bascule
sans rechargement; les épinglés sortent de leur grille d'onglet.
2026-10-11 22:52:22 -04:00
Spark b33b67fcb4 feat(dashboard): prochain jalon et pastille de santé sur les cartes (#17, #18)
Bloc jalon (progression Gitea, échéance FR, retard, ETA journal
avec signal après-échéance — mêmes définitions que le bandeau
du board) et point santé vert/orange/rouge dérivé de
_project_health, info-bulle et lien vers /health du projet.
2026-10-11 22:52:22 -04:00
Spark b4dc8f0d4f chore(release): Kanbante 0.16.1 — icône palette et accordéons du menu
CI / test (push) Successful in 1m50s
2026-10-11 21:12:09 -04:00
Spark e810f5668a test(0.16.1): contrat icône palette et sections accordéon 2026-10-11 21:12:09 -04:00
Spark 2290560627 feat(nav): palette en icône près du titre, sections accordéon (retour #16)
Le bouton Recherche & commandes devient une icône 🔍 seule à
droite du titre (sous le logo en rail replié). Les groupes
deviennent des accordéons: chevron animé, transition douce,
sections fermées mémorisées (appliquées avant le rendu), section
de la page courante toujours ouverte; rail replié = icônes.
2026-10-11 21:12:09 -04:00
Spark d4b3b324f5 chore(release): Kanbante 0.16.0 — layout barre latérale et wallboard verrouillé
CI / test (push) Successful in 1m46s
2026-10-11 20:48:46 -04:00
Spark d395642f16 test(0.16.0): contrat barre latérale et mur — 6 tests adaptés/ajoutés, 171 au total 2026-10-11 20:48:46 -04:00
Spark ec3590d25f feat(wall): hauteur verrouillée 100dvh, pied toujours visible
La diapositive ne grandit plus au-delà de l'écran: plus de barre
de défilement de page, « Ouvertes »/« Fermées »/« EN DIRECT »
toujours affichés au-dessus de la navigation des diapos; cartes
tronquées en fondu + ligne « +N autres cartes en vol »; mode
compact sous 760px de hauteur (TV 720p).
2026-10-11 20:48:46 -04:00
Spark 16f03184bf feat(nav): refonte du layout — barre latérale repliable, topbar mince (issue #16)
Retour de Bruno sur la 0.15.0 (disposition pas conviviale, trop
de place, mobile inchangé): la barre horizontale disparaît.
Barre latérale groupée (Principal/Rituels/Pilotage/Outils),
repliable en rail d'icônes mémorisé sur desktop, tiroir
hors-canvas sur mobile; topbar de 52px (fil d'Ariane + actions).
2026-10-11 20:48:46 -04:00
Spark 6fdf39959c chore(release): Kanbante 0.15.0 — header unifié desktop/mobile
CI / test (push) Successful in 1m54s
2026-10-11 20:15:23 -04:00
Spark 18e1dd0f3d test(0.15.0): header partagé, états actifs, exceptions wall — 5 tests, 170 au total 2026-10-11 20:15:23 -04:00
Spark 58204e621e fix(forecast): semaines calées sur le référentiel UTC du journal
Les horodatages board_events sont en UTC (SQLite): une fermeture
en soirée locale tombait hors des semaines locales et faussait
le débit hebdomadaire.
2026-10-11 20:15:23 -04:00
Spark 2fde103e93 feat(nav): header unifié desktop/mobile (issue #16)
Partiel _header.html unique pour les 19 pages (fin des copies qui
dérivaient): desktop une ligne — primaires + menu Plus + Ctrl K,
page active déduite du gabarit dans render(); mobile ≤900px
burger + panneau ≥44px, Échap/clic extérieur; wallboard sans
header et triage sur le même partiel. nav.js partagé.
2026-10-11 20:15:23 -04:00
Spark 24a1ebb9a6 chore(release): Kanbante 0.14.0 — branche depuis la carte, santé du flux
CI / test (push) Successful in 1m49s
2026-10-11 14:49:38 -04:00
Spark 4c807a4344 test(0.14.0): branche depuis la carte, dwell/zombies — 8 tests, 165 au total 2026-10-11 14:49:38 -04:00
Spark 6453b53df4 feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
2026-10-11 14:49:38 -04:00
Spark 1932d11457 feat(branch): créer la branche depuis la carte (issue #3)
slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
2026-10-11 14:49:38 -04:00
Spark 5b41c5ea74 chore(release): Kanbante 0.13.0 — code lié, PR fusionnée, standup, règles
CI / test (push) Successful in 1m43s
2026-10-11 14:21:11 -04:00
Spark 370dafd266 test(0.13.0): code lié, webhook PR, standup, règles — 10 tests, 157 au total 2026-10-11 14:21:11 -04:00
Spark 7eef8bb604 feat(rules): règles d'automatisation déclaratives (issue #8)
KANBANTE_RULES (JSON validé): conditions colonne/âge/étiquette/
WIP -> add_label / notify (garde quotidienne) / move_top;
évaluation 15 min + POST /api/rules/run; actions journalisées
(événement rule); page /rules. Aucune fermeture possible.
2026-10-11 14:21:11 -04:00
Spark 0b48026752 feat(standup): standup automatique hier/aujourd'hui/blocages (issue #6)
format_standup_text + _standup_data (journal de la veille, focus
du jour, vieillissantes QA/Révision); page /standup copiable,
/api/standup; inclusion optionnelle au briefing ntfy
(KANBANTE_BRIEFING_INCLUDE_STANDUP).
2026-10-11 14:21:11 -04:00
Spark 675ce09083 feat(code): cartes liées au code — branches, PRs, commits (issue #1)
app/code_links.py: appariement pur (#n dans titres/corps/messages,
numéro en jeton de branche). Client: list_branches/list_pulls/
list_commits. Badge sur les cartes (board + fragments live),
section Code lié du panneau détail, /api/issues/{o}/{r}/{n}/code.
2026-10-11 14:21:11 -04:00
Spark 03fa1eb44e chore(release): Kanbante 0.12.0 — focus du jour, prévisions, récit de sprint
CI / test (push) Successful in 1m43s
2026-10-11 13:55:03 -04:00
Spark 8e0c7b8f09 test(0.12.0): prévisions (fonctions pures + pages), focus du jour, récit — 11 tests, 147 au total 2026-10-11 13:55:03 -04:00
Spark 4c5d1dee23 feat(sprint): récit de sprint généré par le journal (issue #4)
app/sprint.py + /sprint/{o}/{r} (rendu markdown, copier/télécharger)
+ /api/sprint en JSON: livré avec temps de cycle, plus lentes,
reporté, chiffres de période. Lien depuis le bandeau milestone.
Navigation: pilules Aujourd'hui/Prévisions et actions palette.
2026-10-11 13:55:03 -04:00
Spark cdabc77ccd feat(forecast): prévisions au débit réel du journal (issue #7)
app/forecast.py: fermetures hebdo (8 sem.), débit moy/min/max sur
4 semaines complètes, ETA en fourchette, silence sans historique.
Dashboard (épuisement du backlog), bandeau milestone (alerte
échéance), page /forecast (sparkline SVG, verdicts), /api/metrics
étendu (weekly_closures, throughput_4w).
2026-10-11 13:55:03 -04:00
Spark fbe58bbf16 feat(today): focus du jour — le rituel des 3 cartes (issue #5)
Table today_picks; POST /api/today/pin (max 3, idempotent) et
/unpin; page /today (picks en direct, candidates de l'encours,
reprises d'hier, récap du jour depuis le journal); bouton 📌 sur
les cartes (délégation capture dans today.js, fragments live
compris); sections dédiées dans le briefing du matin.
2026-10-11 13:55:03 -04:00
Spark 6b9ec1ff99 chore(release): Kanbante 0.11.0 — wallboard navigable
CI / test (push) Successful in 1m42s
2026-10-11 13:37:54 -04:00
Spark 1d3794ba55 test(0.11.0): navigation wallboard (markup multi-diapos, contrat JS) — 3 tests, 136 au total 2026-10-11 13:37:54 -04:00
Spark 715687da66 feat(wall): navigation manuelle (flèches, boutons, pastilles) + position mémorisée + fix SSE
←/→ et contrôles à l'écran changent de projet; navigation manuelle
= pause de rotation 90 s; Espace = pause franche avec badge. La
position survit aux rechargements via sessionStorage (le cycle de
12 projets x 30 s dépassait le refresh de sécurité de 5 min: le
mur semblait figé). Fix: l'écoute SSE utilisait onmessage, inerte
pour les événements nommés 'gitea' du hub.
2026-10-11 13:37:54 -04:00
Spark 2bc8ccaf4f chore(release): Kanbante 0.10.0 — mode Triage
CI / test (push) Successful in 1m43s
2026-10-11 13:17:21 -04:00
Spark 17a131d71b test(0.10.0): triage (sélecteur, file backlog, assign-self, extraits) — 6 tests, 133 au total 2026-10-11 13:17:21 -04:00
Spark 8a32d080b5 feat(triage): mode triage — vider le backlog au clavier (issue #11)
/triage (sélecteur par taille de backlog) + /triage/{o}/{r}
(session plein cadre, file embarquée): 1-5 colonnes, X fermer,
L étiquettes, A s'assigner (nouvel endpoint assign-self +
client.set_issue_assignees, idempotent), Entrée suivante, Z annuler.
Progression + résumé de session; décisions via /api/move et les
API existantes. Entrées: board + palette.
2026-10-11 13:17:21 -04:00
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
76 changed files with 12780 additions and 440 deletions
+610
View File
@@ -1,5 +1,615 @@
# Changelog
## 0.22.0 — 2026-10-14
### Écran de connexion (demande de Bruno)
- Fini la fenêtre Basic Auth du navigateur : page **/login**
professionnelle (carte centrée, marque dégradée, erreur
claire, retour à la page demandée après connexion).
- Session par **cookie signé HMAC** (7 jours, HttpOnly,
SameSite=Lax) ; clé `KANBANTE_SESSION_SECRET` ou dérivée des
identifiants — changer le mot de passe invalide les sessions.
- L'en-tête **Basic reste accepté** pour les scripts ; les API
reçoivent 401, seuls les navigateurs sont redirigés. Lien
**⏻ Sortir** dans le pied de la barre latérale, `/logout`
réel. Protection CSRF sur le formulaire, redirection `next`
limitée aux chemins locaux.
### Barre latérale (demandes de Bruno)
- Le bouton **Replier** quitte le bas de la barre : c'est une
**icône seule, en haut, à droite du bouton recherche**.
- **Accordéon exclusif** : ouvrir une section ferme les
autres — une seule ouverte à la fois (état mémorisé dans
`kanbante-nav-open` ; la section de la page courante prime).
### Chantier B — instantanés d'arrière-plan (validé par Bruno)
- Un **rafraîchisseur** recalcule périodiquement les listes
coûteuses (index des dépôts, étoiles, issues `all` **et**
`open`, jalons — pour chaque projet actif) et les persiste
dans SQLite (table `snapshots`, écriture à chaque réponse du
client). `KANBANTE_SNAPSHOT_INTERVAL` (défaut 120 s, 0 = off).
- Au démarrage, le cache du client est **hydraté** depuis les
instantanés (servis périmés + rafraîchis en tâche de fond,
mécanique SWR) : la première page après un redémarrage ou un
redéploiement ne paie plus Gitea.
- Un **webhook Gitea** rafraîchit immédiatement les
instantanés du dépôt touché. La fraîcheur est affichée dans
le pied de la barre (« 🕐 à l'instant / il y a N min ») :
les données Gitea peuvent dater d'un intervalle au plus,
le journal local reste instantané.
- **Mesuré sur l'instance réelle** (via le proxy de Spark,
latences gonflées) : premier chargement à froid 9,4 s sans
instantanés → après redémarrage avec instantanés : Projets
0,31 s, Focus 0,16 s, Triage 0,07 s, Santé 0,22 s,
Prévisions 0,08 s.
## 0.21.0 — 2026-10-12
### Sélecteur de dépôt intelligent (Capture éclair, demande de Bruno)
- Le champ **Dépôt** de /capture propose les dépôts réels dès
la frappe : liste filtrée en direct, correspondance sur les
deux segments — « kan » suffit pour trouver
`Projets/kanbante`, « pr/kan » aussi. Au focus, épinglés (⭐)
et dépôts récents d'abord.
- **Complétion Tab segment par segment**, comme un shell :
« pr » Tab → `Projets/` (le séparateur est posé dès que le
nom d'organisation est complet), « kan » Tab → `kanbante`.
↑/↓ + Entrée et la souris choisissent dans la liste.
- **Validation douce** : un nom qui n'existe pas dans Gitea est
signalé sous le champ avant l'envoi.
- Nouvel endpoint `GET /api/repos` : liste **exhaustive**
(~130 dépôts chez Bruno — la palette Ctrl+K reste plafonnée
à 60 pour son propre usage), triée épinglés → récents →
archivés. Sélecteur dans `static/js/repo-picker.js`,
réutilisable par d'autres formulaires.
- Vérifié en Playwright sur les données réelles, geste exact
de Bruno inclus : `pr` Tab → `Projets/`, `kan` Tab →
`Projets/kanbante`.
## 0.20.1 — 2026-10-12
### Correctifs tri/regroupement du dashboard (rapportés par Bruno)
- **Tri par date des tableaux réparé** : le comparateur partagé
lisait les dates ISO avec `parseFloat` — il ne retenait que
l'année (« 2026 »), toutes les lignes d'une même année se
retrouvaient à égalité et la colonne « Dernière activité » (vue
Liste) triait dans un ordre arbitraire. La comparaison n'est
numérique que si la valeur entière est un nombre ; les dates
se comparent en chaînes ISO. Même piège écarté pour l'échéance
des Prévisions.
- **Tri et regroupement appliqués aux Épinglés** : la grille
épinglée était exclue des deux outils (choix de la 0.18.0) —
comme c'est la section regardée en premier, changer le Tri ou
cocher « Grouper par propriétaire » semblait ne rien faire.
Les quatre grilles sont maintenant traitées pareil.
- **Sens de premier clic utile** : « Dernière activité »,
« Écart » (Prévisions) et « Zombies » (Santé) trient
décroissant dès le premier clic (`data-first-dir`).
- Tri grille par dates comparé en instants réels (fuseaux
Gitea -04:00/-05:00), cartes sans activité en bas.
- Vérifié en Playwright sur les données réelles : ordres
strictement décroissant/croissant, regroupement et
dégroupement des Épinglés, aucune erreur JS.
## 0.20.0 — 2026-10-12
### Refonte de l'en-tête de la page Projets (demande de Bruno)
- **Titre sobre** : fini le « Bonjour 👋 Voici vos projets
Gitea… » — un « Projets » net, un sous-titre discret, et
l'en-tête occupe deux fois moins de hauteur qu'avant.
- **Bande KPI unifiée** : les 4 statistiques (projets actifs,
fermées · 30 j, déplacements · 30 j, temps de cycle moyen)
vivent dans UNE barre bordée à séparateurs fins, chiffres en
largeur tabulaire ; les indicateurs du journal mènent à la
page Activité au clic.
- **Barre d'outils unique** : onglets, filtre, tri,
regroupement et bascule Grille/Liste — auparavant éparpillés
sur trois rangées — sont alignés dans un seul cadre. Les
onglets deviennent un contrôle segmenté avec pastilles de
compte (plus de dégradé violet ni d'emojis 🟢💤🗄️), le filtre
passe en rectangle sobre.
- Aucun crochet fonctionnel touché (ids et liaisons Alpine
inchangés) ; responsive vérifié en captures 1600 px et
430 px sur les données réelles : aucun débordement, onglets
fonctionnels. Les tuiles `.stat-tile` restent utilisées par
le briefing quotidien.
## 0.19.0 — 2026-10-12
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
borné (16) — le patron que /focus appliquait déjà.
- **Cache stale-while-revalidate (A2)** : les listes Gitea
(dépôts, issues, jalons, étoiles) sont servies immédiatement
même périmées, et rafraîchies en tâche de fond. Fini le
« mur » de rechargement à chaque expiration du cache (30 s) :
seul le tout premier chargement après un démarrage paie le
coût complet. Les mutations vident le cache et désarment les
rafraîchissements en vol (aucune donnée périmée ressuscitée).
- **Prévisions refondues (C1)** : /forecast devient un tableau de
bord des jalons **tous projets** — une ligne par jalon (projet,
échéance, progression, ETA, **écart**, débit), triée par risque,
filtrable, colonnes triables ; le bloc jalon des cartes du
dashboard y pointe directement (ancres). Le débit par projet
reste en dessous, en tableau compact.
- **Santé refondue (C2)** : /health devient un tableau trié par
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
d'un projet gagne un **sélecteur de projet** pour basculer sans
repasser par l'index. Le tri des tableaux est mutualisé
(`static/js/tablesort.js`, dashboard inclus).
- Mesuré sur l'instance réelle (depuis le réseau de test) :
pages chaudes 0,04–0,16 s ; le premier chargement à froid
reste de quelques secondes — le chantier B (instantanés
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
## 0.18.0 — 2026-10-11
### Nouveautés — vague 2, suite (4 cartes de Bruno)
- **Activité récente par projet** (#19) : ligne sur la carte du
dashboard calculée par UNE requête agrégée sur le journal —
« 📈 X fermées · Y déplacements cette semaine », ou « 💤 aucune
activité kanban depuis N j » pour les projets endormis.
- **Actions rapides sur la carte** (#23) : menu ⋯ sur chaque
carte — Board, ⚡ Capturer ici (la page capture accepte
`?repo=` et pré-remplit le dépôt), Triage du backlog, Santé,
Récit de sprint, Gitea. Clavier et tactile soignés (Échap,
clic extérieur).
- **Tri et regroupement** (#21) : tri par activité Gitea,
activité kanban, ouvertes ou nom ; regroupement par
propriétaire en sections repliables ; préférences mémorisées
par navigateur (localStorage).
- **Vue liste dense** (#22) : bascule Grille/Liste persistée —
tableau triable par colonne (Projet, Propriétaire, Ouvertes,
En vol, Prochain jalon, Santé, Dernière activité) réutilisant
les enrichissements des cartes, mêmes onglets et même filtre.
Note : le lien Triage du menu ⋯ est constant — la taille du
backlog « stats » du dashboard ne compte que les cartes placées,
pas le backlog réel ; la page Triage affiche l'état exact.
7 tests ajoutés (185 au total).
## 0.17.0 — 2026-10-11
### Nouveautés — vague 2 « Gestion de projets » (4 cartes de Bruno)
- **Prochain jalon sur la carte projet** (#17) : le jalon ouvert le
plus proche de son échéance s'affiche sur la carte du dashboard —
progression (compteurs Gitea), échéance en français, alerte si
en retard, et ETA du journal en regard avec signal « après
l'échéance » quand la prévision dépasse la date.
- **Pastille de santé par projet** (#18) : point vert/orange/rouge
sur chaque carte active, règles uniques partagées avec /health
(zombies, cartes coincées en QA/Révision, médiane du goulot),
info-bulle détaillée, clic vers la santé du projet.
- **Projets épinglés via les étoiles Gitea** (#20) : section
« ⭐ Épinglés » en tête du dashboard pour les dépôts étoilés ;
bouton ☆/⭐ sur chaque carte (PUT/DELETE /user/starred) — la
vérité vit dans Gitea, aucune table locale ; bascule sans
rechargement (stars.js). Échec de l'API : dashboard intact.
- **Page Projet** (#25) : `/project/{owner}/{repo}` — accueil du
projet : jalons ouverts avec progression/échéances/ETA, santé en
résumé (goulot, zombies), débit des prévisions, activité
récente du journal, notes du projet et actions (board, capture,
triage, santé, wallboard figé, Gitea). Le titre des cartes du
dashboard pointe désormais ici ; le board reste à un clic
(lien 📋 Board sur chaque carte et bouton principal).
7 tests ajoutés (178 au total).
## 0.16.1 — 2026-10-11
### Améliorations (retour de Bruno sur la barre latérale)
- Le bouton « Recherche & commandes » devient une **icône 🔍
seule**, placée **à droite du titre** dans l'en-tête de la barre
latérale (info-bulle et Ctrl+K inchangés) ; en rail replié, elle
passe sous le logo, toujours accessible.
- Les groupes du menu deviennent des **sections accordéon** :
en-têtes cliquables au look moderne (micro-libellés, chevron
animé), ouverture/fermeture en transition douce (technique
`grid-template-rows`), **état mémorisé par navigateur** appliqué
avant le premier rendu. La section de la page courante s'ouvre
toujours automatiquement. En rail replié, l'accordéon s'efface :
toutes les icônes restent visibles.
## 0.16.0 — 2026-10-11
### Nouveautés
- **Layout repensé** (issue #16, retour de Bruno sur la 0.15.0 :
disposition pas conviviale, trop de place, mobile inchangé) —
la barre de pilules horizontale disparaît au profit d'une
**barre latérale** : navigation groupée (Principal, Rituels,
Pilotage, Outils), bouton palette en tête, **repliable en rail
d'icônes** sur desktop (état mémorisé par navigateur), et
**tiroir hors-canvas** sur mobile (burger dans une topbar mince
de 52 px : fil d'Ariane + actions contextuelles seulement).
Le partiel `_header.html` reste l'unique source du cadre.
- **Wallboard** : hauteur verrouillée à l'écran (`100dvh`,
`overflow: hidden`) — **plus aucune barre de défilement de
page** ; le pied de diapositive (« ✅ Fermées aujourd'hui »,
« 📋 Ouvertes », « ● EN DIRECT ») reste **toujours visible**,
au-dessus de la zone réservée à la navigation des diapos ; la
liste des cartes se tronque avec un fondu et une ligne
« … +N autres cartes en vol » ; mode compact sous 760 px de
hauteur (TV 720p).
6 tests adaptés/ajoutés (171 au total).
## 0.15.0 — 2026-10-11
### Nouveautés
- **Header refait** (issue #16, créée et spécifiée par Bruno) :
les 19 copies de markup de header sont remplacées par un
**partiel unique `_header.html`**. Desktop : une seule ligne —
5 entrées primaires (Projets, Focus, Aujourd'hui, Recherche,
Capture), menu « Plus ▾ » pour les 9 autres, Ctrl+K à droite,
page active marquée (y compris dans « Plus »). Mobile
(≤ 900 px) : burger + panneau plein largeur, cibles ≥ 44 px,
fermeture Échap / clic extérieur. La page active est déduite du
gabarit dans `render()` (`active_nav`) — une seule source de
vérité. Le wallboard garde son plein écran sans header et la
session triage utilise le même partiel. `static/js/nav.js`
partagé, aucune dépendance ajoutée.
### Corrigé
- Prévisions : les semaines se calent désormais sur le référentiel
du journal (UTC, comme les horodatages `board_events`) — une
fermeture journalisée en soirée locale (déjà le lendemain en
UTC) tombait hors de toutes les semaines et faussait le débit.
5 tests ajoutés (170 au total).
## 0.14.0 — 2026-10-11
### Nouveautés
- **Créer la branche depuis la carte** (issue #3) : le panneau
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
max, nom libre accepté après validation git-ref) et la crée
dans Gitea depuis la branche par défaut
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
« En cours » (journalisé, source `branch`, étiquette synchronisée)
et la branche est aussitôt reconnue par le code lié (#1) et par
l'automatisation de fusion (#2). Réponse idempotente si la
branche existe déjà.
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
séjours par colonne depuis le journal (entrée → déplacement /
fermeture) : temps médian et moyen par colonne, colonne la plus
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
(défaut 30). Pages `/health` (résumé global) et
`/health/{owner}/{repo}` (détail + actions rapides sur les
zombies : commenter « toujours pertinent ? », remettre au
Backlog, fermer — via les API existantes).
8 tests ajoutés (165 au total). **La feuille de route
« Différenciation » est complète : 15/15.**
## 0.13.0 — 2026-10-11
### Nouveautés
- **Cartes liées au code** (issue #1) : `app/code_links.py` apparie
branches (`feature/12-x`, `issue-12`), PRs (références `#n` dans
titre/corps ou branche) et commits récents (références `#n`).
Badge `⑂` sur les cartes (board + fragments temps réel), section
« 🧬 Code lié » dans le panneau détail, endpoint
`GET /api/issues/{o}/{r}/{n}/code`.
- **PR fusionnée → carte Terminé** (issue #2) : le webhook accepte
`pull_request` fermée + fusionnée ; les issues liées sont
fermées dans Gitea si besoin et leurs cartes passent en Terminé,
idempotent, journalisé source `webhook`.
- **Standup automatique** (issue #6) : page `/standup` +
`/api/standup` — fermé/déplacé **hier** (journal, titres
résolus), focus **aujourd'hui**, **blocages** (cartes qui
vieillissent en QA/Révision) ; bouton copier ; inclusion
optionnelle au briefing ntfy (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`).
- **Règles d'automatisation** (issue #8) : moteur déclaratif
`KANBANTE_RULES` (JSON, validé : conditions `column`,
`min_age_days`, `label`, `wip_over` ; actions `add_label`,
`notify`, `move_top`), évalué toutes les 15 min par le
planificateur et à la demande ; chaque action est journalisée
(événement `rule`, source `rule`) ; page `/rules` avec état et
dernière évaluation. Garde-fou : aucune action de fermeture
ou de déplacement entre colonnes.
10 tests ajoutés (157 au total).
## 0.12.0 — 2026-10-11
### Nouveautés
- **Focus du jour** (issue #5) : le rituel des 3 cartes. Table
`today_picks` ; bouton 📌 sur chaque carte du board (et sur les
candidates) ; page `/today` calme — picks du jour avec leur
colonne en direct, candidates de l'encours, **reprises d'hier
non terminées** reproposées, récapitulatif « ça a bougé
aujourd'hui » tiré du journal. Le briefing du matin inclut les
sections « Focus du jour » et « À reprendre d'hier ».
`POST /api/today/pin` (max 3, idempotent) et `/unpin`.
- **Prévisions** (issue #7) : `app/forecast.py` — fermetures par
semaine depuis le journal (8 semaines), débit moy/min/max sur
les 4 dernières semaines complètes, projection en **fourchette**
(jamais un faux chiffre précis) et silence explicite sans
historique. Affichées sur le dashboard (épuisement du backlog),
dans le bandeau milestone (avec alerte si l'ETA dépasse
l'échéance) et sur `/forecast` (sparkline SVG inline, verdict
par milestone). `/api/metrics` expose `weekly_closures` et
`throughput_4w`.
- **Récit de sprint** (issue #4) : `app/sprint.py` + page
`/sprint/{owner}/{repo}?milestone=` — le bilan markdown du
milestone écrit par le journal : livré (avec temps de cycle par
carte), cartes les plus lentes, reporté avec colonnes, chiffres
de la période ; boutons **Copier** et **Télécharger .md**,
endpoint `GET /api/sprint/...` en JSON, lien « 📖 Récit de
sprint » dans le bandeau milestone du board.
11 tests ajoutés (147 au total).
## 0.11.0 — 2026-10-11
### Nouveautés
- **Wallboard navigable** : flèches `←` / `→` du clavier, boutons
❮ ❯ et pastilles cliquables pour changer de projet à la demande ;
une navigation manuelle met la rotation en pause 90 s, `Espace`
bascule une pause franche (badge visible). La **position est
mémorisée** (sessionStorage) entre les rechargements : le mur ne
repart plus à la première diapo à chaque refresh — avec 12
projets actifs × 30 s, le cycle complet (6 min) dépassait le
rechargement de sécurité (5 min), rendant les dernières diapos
inatteignables et donnant l'impression d'un mur figé.
### Correction
- L'écoute temps réel du wallboard utilisait `onmessage`, qui ne
se déclenche jamais pour les événements SSE **nommés** (`gitea`)
émis par le hub : les rechargements en direct ne partaient pas,
seul le filet de 5 minutes fonctionnait. Corrigé en
`addEventListener('gitea', …)`.
3 tests ajoutés (136 au total).
## 0.10.0 — 2026-10-11
### Nouveautés
- **Mode Triage** (issue #11) : vider un backlog au clavier, façon
« inbox zero ». `/triage` liste les projets actifs et la taille
de leur backlog ; `/triage/{owner}/{repo}` présente les cartes
du Backlog **une par une, plein cadre** (titre, extrait du corps,
étiquettes, âge), file embarquée côté client — aucun rechargement
entre cartes. Décisions : `1`–`5` envoient dans À faire / En
cours / QA / Révision / Terminé, `X` ferme, `L` ouvre le panneau
d'étiquettes (bascule via l'API existante), `A` s'assigne l'issue
(nouvel endpoint `POST /api/issues/{o}/{r}/{n}/assign-self`,
idempotent), `Entrée` passe à la suivante, `Z` annule le dernier
déplacement. Barre de progression « Carte n / N » et résumé final
par décision. Les raccourcis sont affichés en permanence et
cliquables. Points d'entrée : bouton « Triage du backlog » sur le
board et action dans la palette Ctrl+K.
6 tests ajoutés (133 au total).
## 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.
+108 -4
View File
@@ -5,14 +5,37 @@
## 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
- ⚡ **Temps réel** — un webhook Gitea invalide le cache et pousse un événement SSE : le board ouvert se met à jour tout seul (en douceur, sans interrompre une saisie ni un drag)
- 🏷️ **Visible dans Gitea** — les colonnes intermédiaires sont reflétées par des étiquettes `kanban:*` sur les issues, et un board peut se filtrer **par milestone** (échéance + progression façon sprint)
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
- 🎯 **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
- 🌅 **Focus du jour** — `/today` : jusqu'à 3 cartes épinglées (bouton 📌 sur les cartes), candidates de l'encours, reprises d'hier non terminées, récapitulatif du soir ; intégrées au briefing
- 🔮 **Prévisions** — débit réel du journal (fermetures/semaine), date d'épuisement du backlog et ETA des milestones en fourchette honnête, sur le dashboard, le bandeau milestone et `/forecast` (sparkline SVG)
- 📖 **Récit de sprint** — le bilan markdown d'un milestone (livré, cycles, plus lentes, reporté) généré depuis le journal, page `/sprint/...` + copie/téléchargement en un clic
- 🧬 **Code lié** — branches, PRs et commits référant `#n` sur la carte (badge `⑂`) et dans le panneau détail (section « Code lié »)
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
- 🔄 **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
@@ -89,14 +112,31 @@ sudo systemctl enable --now docker
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active la Basic Auth dès que les deux sont définis |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
| `KANBANTE_SESSION_SECRET` | *(dérivé)* | Clé HMAC du cookie de session ; par défaut dérivée des identifiants (changer le mot de passe invalide les sessions) |
| `KANBANTE_SNAPSHOT_INTERVAL` | `120` | Secondes entre les rafraîchissements d'instantanés d'arrière-plan (chantier B) ; `0` = désactivé |
| `KANBANTE_ACTIVE_WINDOW_DAYS` | `365` | Un dépôt non archivé mis à jour depuis moins de N jours est « actif » |
| `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_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
| `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é
@@ -138,7 +178,25 @@ 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 | `/triage` · `/triage/{owner}/{repo}` | Mode Triage : sélecteur de projet puis session clavier sur le backlog |
| GET | `/today` | Focus du jour : les 3 cartes épinglées, candidates, reprises d'hier |
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
| POST | `/api/issues/{o}/{r}/{n}/assign-self` | S'assigner une issue (utilisateur du jeton) |
| 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 |
@@ -146,6 +204,52 @@ kanbante/
| POST | `/api/issues/{owner}/{repo}/{n}/comments` | Commenter l'issue dans Gitea |
| GET | `/api/events` | Derniers événements du journal (`?repo=`, `?limit=`) |
| GET | `/api/metrics` | Débit + temps de cycle moyen (`?repo=`, `?window=`) |
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
| 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) |
+126
View File
@@ -0,0 +1,126 @@
"""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_standup_text(data: dict) -> str:
"""Automatic standup (issue #6): yesterday / today / blockers."""
lines = [f"🗣️ Standup — {data['today_label']}"]
closed = data["closed_yesterday"]
lines.append(
f"\n✅ Hier ({data['yesterday_label']}) : {len(closed)} fermée(s), "
f"{data['moved_yesterday']} déplacement(s)"
)
for item in closed[:8]:
lines.append(
f" • {item['repo']} #{item['number']} « {item['title']} »"
)
lines.append("\n📌 Aujourd'hui :")
if data["picks"]:
for pick in data["picks"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
elif data["focus_titles"]:
for title in data["focus_titles"][:5]:
lines.append(f" • {title}")
lines.append(" (aucun focus épinglé — pense à /today)")
else:
lines.append(" • Rien en cours. Journée libre ou triage à faire !")
lines.append("\n🚧 Blocages :")
if data["blockers"]:
for card in data["blockers"][:6]:
lines.append(
f" • {card['repo']} #{card['number']} « {card['title']} »"
f" — {card['days']} j en {card['column_label']}"
)
else:
lines.append(" • Aucun blocage signalé. ✨")
return "\n".join(lines)
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.")
if data.get("today_picks"):
lines.append("\n🌅 Focus du jour :")
for pick in data["today_picks"]:
mark = "✅" if pick["state"] == "closed" else "📌"
lines.append(
f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
if data.get("carry_over"):
lines.append("\n↩️ À reprendre d'hier (non terminées) :")
for pick in data["carry_over"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
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.")
if data.get("standup"):
lines.append("\n" + data["standup"])
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)
+127
View File
@@ -0,0 +1,127 @@
"""Code links (roadmap #1): what code belongs to a card?
Pure matching helpers — a branch, pull request or commit is linked
to an issue when it references the issue number the way developers
actually write it: `#12` in a title/body/message, or the number as
a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
"""
import re
import unicodedata
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
def extract_issue_refs(text: str | None) -> set[int]:
"""All #n references in a text (titles, bodies, messages)."""
if not text:
return set()
return {int(m.group(1)) for m in _REF_RE.finditer(text)}
def branch_issue_number(branch_name: str | None) -> int | None:
"""The issue number encoded in a branch name, if any."""
if not branch_name:
return None
m = _BRANCH_TOKEN_RE.search(branch_name)
return int(m.group(1)) if m else None
def slugify_title(title: str, max_len: int = 40) -> str:
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
normalized = unicodedata.normalize("NFKD", title or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or "issue"
def branch_name_for(number: int, title: str) -> str:
"""The branch a card would create (issue #3): issue-<n>-<slug>.
The name is recognized back by branch_issue_number, which is
what links the future PR to this card (issue #2).
"""
return f"issue-{number}-{slugify_title(title)}"
def find_code_links(
branches: list[dict],
pulls: list[dict],
commits: list[dict],
number: int,
) -> dict:
"""Linked branches/PRs/commits for one issue number."""
linked_branches = [
b.get("name", "")
for b in branches
if branch_issue_number(b.get("name")) == number
]
linked_pulls = []
for pr in pulls:
head_ref = ((pr.get("head") or {}).get("ref")) or ""
refs = extract_issue_refs(pr.get("title")) | extract_issue_refs(
pr.get("body")
)
if number in refs or branch_issue_number(head_ref) == number:
linked_pulls.append(
{
"number": pr.get("number"),
"title": pr.get("title", ""),
"state": pr.get("state", "open"),
"merged": bool(pr.get("merged")),
"html_url": pr.get("html_url", ""),
"branch": head_ref,
}
)
linked_commits = []
for commit in commits:
message = ((commit.get("commit") or {}).get("message")) or ""
if number in extract_issue_refs(message):
linked_commits.append(
{
"sha": (commit.get("sha") or "")[:7],
"message": message.splitlines()[0] if message else "",
"html_url": commit.get("html_url", ""),
"date": (
((commit.get("commit") or {}).get("author") or {}).get(
"date", ""
)
or ""
)[:10],
}
)
# Best PR to badge: a merged one beats an open one beats closed.
pr_badge = None
for wanted in ("merged", "open", "closed"):
for pr in linked_pulls:
state = (
"merged" if pr["merged"]
else "open" if pr["state"] == "open" else "closed"
)
if state == wanted:
pr_badge = {
"number": pr["number"],
"state": state,
"label": {
"merged": "fusionnée",
"open": "ouverte",
"closed": "fermée",
}[state],
}
break
if pr_badge:
break
return {
"branches": linked_branches,
"pulls": linked_pulls,
"commits": linked_commits,
"summary": {
"branches": len(linked_branches),
"commits": len(linked_commits),
"pr": pr_badge,
},
}
+112 -3
View File
@@ -8,9 +8,15 @@ Environment variables:
GITEA_URL Base URL of the Gitea instance.
GITEA_TOKEN Gitea API token (read/write issues).
KANBANTE_DB_PATH Path of the local SQLite database.
KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Basic-auth password.
KANBANTE_AUTH_USER Login username (with _PASSWORD: enables auth).
KANBANTE_AUTH_PASSWORD Login password.
KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured.
KANBANTE_SESSION_SECRET HMAC key for the login session cookie
(default: derived from the credentials —
rotating the password invalidates sessions).
KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot
refreshes (chantier B, default 120;
0 disables the refresher).
KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many
days counts as "active" (default 365).
KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30).
@@ -23,6 +29,64 @@ Environment variables:
warns, moves are never blocked.
KANBANTE_AGING_DAYS Days in the same column before a card is
flagged as aging (default 7; "stale" at 2x).
KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook
(POST /webhooks/gitea). When set, the
X-Gitea-Signature HMAC is enforced.
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
columns to Gitea labels named kanban:<col>.
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).
KANBANTE_BRIEFING_INCLUDE_STANDUP
"1" appends the automatic standup
(issue #6) to the morning briefing.
KANBANTE_RULES Automation rules (issue #8) as a JSON
array: [{"name": ..., "when":
{"column": "review", "min_age_days": 3},
"then": {"notify": true}}]. Conditions:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
"""
import os
@@ -31,7 +95,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.3.0"
APP_VERSION = "0.22.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -81,6 +145,27 @@ class Settings:
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}"}'
briefing_include_standup: bool = False
rules_raw: str = ""
zombie_days: int = 30
session_secret: str = ""
snapshot_interval: int = 120
@property
def auth_enabled(self) -> bool:
@@ -104,4 +189,28 @@ def get_settings() -> Settings:
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
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}"}',
),
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
rules_raw=os.getenv("KANBANTE_RULES", ""),
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""),
snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120),
)
+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.22.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}"
+123
View File
@@ -0,0 +1,123 @@
"""Flow health (roadmap #9): bottlenecks and zombie cards.
Boards show state; this module reads the event journal for health:
how long cards actually stay in each column (dwell samples are the
intervals between an entry and the next move/close), and which
open cards have had no activity for a long time (zombies).
"""
import statistics
from datetime import datetime
from app.models import KANBAN_COLUMNS
def parse_ts(raw) -> datetime | None:
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
text = str(raw or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
"""Dwell samples (in days) per column, rebuilt from the journal.
A stay starts when a card enters a column (created / moved /
reopened) and ends at its next move or close. The first stay of
a card whose journal starts mid-flight is unknowable and is
simply not sampled.
"""
by_issue: dict[tuple[str, int], list[dict]] = {}
for event in events:
by_issue.setdefault(
(event["repo_full_name"], event["issue_number"]), []
).append(event)
dwell: dict[str, list[float]] = {}
for issue_events in by_issue.values():
ordered = sorted(
issue_events, key=lambda e: str(e.get("created_at") or "")
)
current: str | None = None
since: datetime | None = None
for event in ordered:
at = parse_ts(event.get("created_at"))
if at is None:
continue
kind = event["event_type"]
if kind == "created":
if current is None:
current = event.get("to_column") or "backlog"
since = at
elif kind == "moved":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
elif current is None and event.get("from_column"):
# The stay in from_column began before the journal
# window: only the new stay can be timed.
pass
current = event.get("to_column") or current
since = at
elif kind == "closed":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
current = "done"
since = at
elif kind == "reopened":
current = event.get("to_column") or "backlog"
since = at
return dwell
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
"""Median dwell per column, in board order."""
summary = []
for column in KANBAN_COLUMNS:
samples = dwell.get(column) or []
if not samples:
continue
summary.append(
{
"column": column,
"count": len(samples),
"median_days": round(statistics.median(samples), 1),
"avg_days": round(sum(samples) / len(samples), 1),
}
)
return summary
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
"""The bottleneck: slowest column (done excluded) with data."""
candidates = [
row for row in summary
if row["column"] != "done" and row["count"] >= min_samples
]
if not candidates:
return None
return max(candidates, key=lambda r: r["median_days"])
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
"""Most recent journal timestamp per (repo, issue)."""
latest: dict[tuple[str, int], datetime] = {}
for event in events:
at = parse_ts(event.get("created_at"))
if at is None:
continue
key = (event["repo_full_name"], event["issue_number"])
if key not in latest or at > latest[key]:
latest[key] = at
return latest
+127
View File
@@ -0,0 +1,127 @@
"""Forecasts (roadmap #7): honest ETAs from the event journal.
Throughput = issues closed per week, counted from board_events.
Projections are deliberately given as a RANGE (best/worst weekly
rate over the last 4 complete weeks) rather than a fake-precise
date, and are withheld entirely when the journal has no closures
to learn from — "not enough history" is an answer, too.
"""
from datetime import date, datetime, timedelta, timezone
def _journal_today() -> date:
"""'Today' in the journal's frame: board_events stamps are UTC
(SQLite datetime('now')), so week buckets anchor to UTC —
otherwise closures logged in the local evening (already
tomorrow in UTC) fall outside every bucket."""
return datetime.now(timezone.utc).date()
MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
MONTHS_FR_SHORT = [
"janv.", "févr.", "mars", "avr.", "mai", "juin",
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
]
def french_date(d: date, short: bool = False) -> str:
months = MONTHS_FR_SHORT if short else MONTHS_FR
return f"{d.day} {months[d.month - 1]}"
def _event_date(event: dict) -> date | None:
raw = str(event.get("created_at") or "")
try:
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
except ValueError:
return None
def weekly_closures(events: list[dict], weeks: int = 8,
today: date | None = None) -> list[dict]:
"""Closure counts per ISO week, oldest first, current week last."""
today = today or _journal_today()
current_monday = today - timedelta(days=today.weekday())
buckets: list[dict] = []
for i in range(weeks - 1, -1, -1):
start = current_monday - timedelta(weeks=i)
buckets.append({"start": start, "count": 0})
by_start = {b["start"]: b for b in buckets}
for event in events:
if event.get("event_type") != "closed":
continue
day = _event_date(event)
if day is None:
continue
monday = day - timedelta(days=day.weekday())
if monday in by_start:
by_start[monday]["count"] += 1
return buckets
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
"""avg/min/max closures per week over the last COMPLETE weeks.
The current (partial) week is excluded: on Monday morning it
would crush the average for no reason. Returns None when the
complete weeks contain no closure at all.
"""
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
if not complete:
return None
counts = [b["count"] for b in complete]
if sum(counts) == 0:
return None
return {
"avg": sum(counts) / len(counts),
"min": min(counts),
"max": max(counts),
"weeks": len(counts),
}
def estimate(remaining: int, weekly: list[dict],
today: date | None = None) -> dict | None:
"""Projected completion for `remaining` items at the real pace.
Returns {"done": True} when nothing remains, None when there is
no throughput to project from, else dates: central (avg rate),
earliest (best week), latest (worst nonzero week, if any).
"""
today = today or _journal_today()
if remaining <= 0:
return {"done": True}
stats = throughput_stats(weekly)
if stats is None:
return None
def at_rate(rate: float) -> date:
days = remaining / rate * 7
return today + timedelta(days=max(1, round(days)))
result = {
"done": False,
"avg_per_week": round(stats["avg"], 1),
"central": at_rate(stats["avg"]),
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
}
return result
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
"""One-line French label for banners and cards (or None)."""
if est is None:
return None
if est.get("done"):
return f"✅ {done_text}"
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
if est.get("earliest") and est.get("latest"):
label += (
f" (fourchette {french_date(est['earliest'], short=True)}"
f" – {french_date(est['latest'], short=True)})"
)
return label
+312 -26
View File
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
- .env loading targets the project root only (no hardcoded home path).
"""
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -60,6 +62,12 @@ class GiteaClient:
)
self._user: dict | None = None
self._cache: dict[str, tuple[float, Any]] = {}
self._refreshing: set[str] = set()
self._cache_gen = 0
# Snapshot bookkeeping (chantier B): cache key -> UTC time
# the payload was fetched from Gitea, for the freshness
# label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {}
async def aclose(self) -> None:
await self.client.aclose()
@@ -105,20 +113,146 @@ class GiteaClient:
page += 1
return items
def _cache_get(self, key: str) -> Any | None:
if self.cache_ttl <= 0:
return None
hit = self._cache.get(key)
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
return None
def _cache_put(self, key: str, value: Any) -> None:
if self.cache_ttl > 0:
self._cache[key] = (time.monotonic(), value)
self._persist_snapshot(key, value)
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table.
Persistence must never break a read: any failure (DB
locked, unserializable payload) is logged and swallowed.
"""
try:
from app import models
models.save_snapshot(key, value)
import datetime
self._snapshot_times[key] = datetime.datetime.now(
datetime.timezone.utc
).strftime("%Y-%m-%d %H:%M:%S")
except Exception as exc:
logger.debug("snapshot persist failed for %s: %s", key, exc)
def hydrate_from_snapshots(self) -> int:
"""Preload the cache from persisted snapshots (startup).
Entries are inserted with an already-stale timestamp: the
SWR layer serves them instantly AND schedules a background
refresh, so the first page after a restart renders from
local data (tens of ms) while Gitea data catches up.
Returns the number of entries hydrated.
"""
if self.cache_ttl <= 0:
return 0
try:
from app import models
snapshots = models.load_snapshots()
except Exception as exc:
logger.warning("snapshot hydration failed: %s", exc)
return 0
stale_at = time.monotonic() - self.cache_ttl - 1.0
for key, (payload, updated_at) in snapshots.items():
self._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at
if snapshots:
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
return len(snapshots)
def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
if not self._snapshot_times:
return None
return max(self._snapshot_times.values())
async def _force(self, key: str, fetcher: Callable[[], Awaitable[Any]]) -> Any:
"""Fetch now, bypassing freshness (background refresher)."""
value = await fetcher()
self._cache_put(key, value)
return value
async def refresh_starred(self) -> list[dict]:
return await self._force(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def refresh_repo_issues(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
return await self._force(
f"issues:{owner}/{repo}:{state}",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
async def refresh_milestones(
self, owner: str, repo: str
) -> list[dict]:
return await self._force(
f"milestones:{owner}/{repo}:open",
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state="open"
),
)
def _cache_clear(self) -> None:
self._cache.clear()
# Invalidate in-flight background refreshes: a refresh that
# started before a mutation must not resurrect stale data.
self._cache_gen += 1
async def _cached_list(
self, key: str, fetcher: Callable[[], Awaitable[Any]]
) -> Any:
"""Stale-while-revalidate cache for list endpoints (0.19.0).
Fresh (< TTL): serve the cache. Stale: serve the cached
value IMMEDIATELY and refresh it in the background (once
per key) — the user never waits on Gitea for a page when
any earlier answer exists. Absent: fetch and wait.
Mutations clear the cache (see _cache_clear), so writes are
never hidden by a stale entry.
"""
if self.cache_ttl <= 0:
return await fetcher()
hit = self._cache.get(key)
if hit is not None:
if (time.monotonic() - hit[0]) < self.cache_ttl:
return hit[1]
if key not in self._refreshing:
self._refreshing.add(key)
asyncio.create_task(
self._refresh_entry(key, fetcher, self._cache_gen)
)
return hit[1]
value = await fetcher()
self._cache_put(key, value)
return value
async def _refresh_entry(
self,
key: str,
fetcher: Callable[[], Awaitable[Any]],
generation: int,
) -> None:
try:
value = await fetcher()
if generation == self._cache_gen:
self._cache_put(key, value)
except Exception as exc:
logger.debug("background refresh failed for %s: %s", key, exc)
finally:
self._refreshing.discard(key)
def invalidate_cache(self) -> None:
"""Drop every cached list response (called on webhook events)."""
self._cache_clear()
# ── API ────────────────────────────────────────────────
@@ -136,21 +270,25 @@ class GiteaClient:
self._user = await self._get("/user")
return self._user
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, cached)."""
cached = self._cache_get("search_repos")
if cached is not None:
return cached
async def _fetch_search_repos(self) -> list[dict]:
params: dict[str, Any] = {}
try:
user = await self.get_user()
if user and user.get("id"):
params["uid"] = user["id"]
except Exception as exc: # no/invalid token → public search
logger.debug("get_user failed, falling back to public search: %s", exc)
repos = await self._get_all_pages("/repos/search", **params)
self._cache_put("search_repos", repos)
return repos
logger.debug(
"get_user failed, falling back to public search: %s", exc
)
return await self._get_all_pages("/repos/search", **params)
async def search_repos(self) -> list[dict]:
"""All repos visible to the token owner (paginated, SWR-cached)."""
return await self._cached_list("search_repos", self._fetch_search_repos)
async def refresh_search_repos(self) -> list[dict]:
"""Force-fetch the repo index (background refresher)."""
return await self._force("search_repos", self._fetch_search_repos)
async def get_repo(self, owner: str, repo: str) -> dict:
return await self._get(f"/repos/{owner}/{repo}")
@@ -168,18 +306,18 @@ 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)
if cached is not None:
return cached
issues = await self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/issues", state=state
),
)
self._cache_put(key, issues)
return issues
async def get_all_issues(
self, repos: list[dict], state: str = "open"
@@ -221,6 +359,110 @@ class GiteaClient:
r.raise_for_status()
return r.json()
# ── Labels (kanban:* column mirroring) ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
return await self._get_all_pages(f"/repos/{owner}/{repo}/labels")
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
r = await self.client.post(
f"/repos/{owner}/{repo}/labels",
json={"name": name, "color": color.lstrip("#")},
)
r.raise_for_status()
return r.json()
async def ensure_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
"""Return the repo label with this name, creating it if needed."""
key = f"{owner}/{repo}"
cache = self.__dict__.setdefault("_label_cache", {})
if key not in cache:
cache[key] = {l["name"]: l for l in await self.get_labels(owner, repo)}
if name not in cache[key]:
cache[key][name] = await self.create_label(owner, repo, name, color)
return cache[key][name]
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [label_id]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
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}"
return await self._cached_list(
key,
lambda: self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
),
)
async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth)."""
return await self._cached_list(
"user:starred", lambda: self._get_all_pages("/user/starred")
)
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
"""Star / unstar a repo for the token user."""
if starred:
r = await self.client.put(f"/user/starred/{owner}/{repo}")
else:
r = await self.client.delete(f"/user/starred/{owner}/{repo}")
r.raise_for_status()
self._cache_clear()
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
) -> dict:
@@ -244,3 +486,47 @@ class GiteaClient:
r.raise_for_status()
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
"""Create a branch from another one (issue #3)."""
return await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={
"new_branch_name": new_branch,
"old_branch_name": old_branch,
},
)
async def list_branches(self, owner: str, repo: str) -> list[dict]:
"""All branches of a repo (code links, issue #1)."""
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
async def list_pulls(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
"""All pull requests of a repo (code links, issue #1)."""
return await self._get_all_pages(
f"/repos/{owner}/{repo}/pulls", state=state
)
async def list_commits(
self, owner: str, repo: str, limit: int = 75
) -> list[dict]:
"""Most recent commits of a repo (code links, issue #1)."""
data = await self._get(f"/repos/{owner}/{repo}/commits", limit=limit)
return data if isinstance(data, list) else []
async def set_issue_assignees(
self, owner: str, repo: str, issue_number: int, logins: list[str]
) -> dict:
"""Replace the assignee set of an issue (triage "assign me")."""
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{issue_number}",
json={"assignees": logins},
)
r.raise_for_status()
self._cache_clear()
return r.json()
+3078 -39
View File
File diff suppressed because it is too large Load Diff
+141 -2
View File
@@ -11,12 +11,13 @@ and upgrades older databases in place (position column, notes
updated_at, cleanup of rows carrying an unknown column name).
"""
import json
import os
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:
@@ -79,6 +80,24 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS snapshots (
key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
@@ -234,9 +253,97 @@ def reconcile_board_state(
return changed
def get_picks(pick_date: str) -> list[dict]:
"""The day's focus picks (issue #5), in pinning order."""
with get_db() as conn:
rows = conn.execute(
"SELECT repo_full_name, issue_number FROM today_picks "
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
(pick_date,),
).fetchall()
return [dict(row) for row in rows]
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
(pick_date, repo_full_name, issue_number),
)
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? "
"AND repo_full_name = ? AND issue_number = ?",
(pick_date, repo_full_name, issue_number),
)
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),
)
# ── Snapshots (chantier B, 0.22.0) ─────────────────────────
# Persisted copies of the Gitea list responses the pages read
# (repos, issues, milestones, starred). The client cache is
# hydrated from these rows at startup, so even the first page
# after a restart renders from local data while the background
# refresher re-fetches from Gitea.
def save_snapshot(key: str, payload) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO snapshots (key, payload, updated_at) "
"VALUES (?, ?, datetime('now')) "
"ON CONFLICT(key) DO UPDATE SET payload = excluded.payload, "
"updated_at = excluded.updated_at",
(key, json.dumps(payload)),
)
def load_snapshots() -> dict:
"""All snapshots as {key: (payload, updated_at_utc_str)}."""
with get_db() as conn:
rows = conn.execute(
"SELECT key, payload, updated_at FROM snapshots"
).fetchall()
out = {}
for row in rows:
try:
out[row["key"]] = (json.loads(row["payload"]), row["updated_at"])
except (ValueError, TypeError):
continue
return out
def latest_snapshot_at() -> str | None:
"""UTC timestamp (SQLite format) of the freshest snapshot."""
with get_db() as conn:
row = conn.execute(
"SELECT MAX(updated_at) AS latest FROM snapshots"
).fetchone()
return row["latest"] if row else None
# ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
def log_event(
@@ -310,6 +417,38 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
return {row["issue_number"]: row["entered_at"] for row in rows}
def get_project_activity(days: int = 7) -> dict:
"""Per-repo journal activity in ONE aggregated pass (issue #19).
For every repo present in the journal: closures and moves of
the last `days` days (journal stamps are UTC strings), plus
the most recent event ever seen for that repo.
"""
from datetime import datetime, timedelta, timezone
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
with get_db() as conn:
rows = conn.execute(
"""
SELECT repo_full_name,
SUM(CASE WHEN event_type = 'closed' AND created_at >= ?
THEN 1 ELSE 0 END),
SUM(CASE WHEN event_type = 'moved' AND created_at >= ?
THEN 1 ELSE 0 END),
MAX(created_at)
FROM board_events
GROUP BY repo_full_name
""",
(cutoff, cutoff),
).fetchall()
return {
r[0]: {"closed": r[1] or 0, "moved": r[2] or 0, "last_at": r[3]}
for r in rows
}
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
"""Throughput and cycle time computed from the journal.
+111
View File
@@ -0,0 +1,111 @@
"""Automation rules (roadmap #8): small declarative IFTTT for the board.
Rules live in KANBANTE_RULES (JSON array) — deliberately boring:
[{"name": "Relance révision",
"when": {"column": "review", "min_age_days": 3},
"then": {"notify": true}},
{"name": "Urgent qui vieillit",
"when": {"column": "in_progress", "min_age_days": 7},
"then": {"add_label": "urgent"}}]
Conditions: column, min_age_days (time in the current column),
label (present on the card), wip_over (column holds MORE cards
than N — then every card of the column matches).
Actions: add_label, notify (ntfy), move_top (of its column).
Guardrail, by design: there is NO close/delete/move action — a
rule can never close an issue or move a card between columns.
"""
import json
from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
ACTION_KEYS = {"add_label", "notify", "move_top"}
def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"""Validate KANBANTE_RULES; returns (rules, human errors)."""
if not raw or not raw.strip():
return [], []
try:
data = json.loads(raw)
except ValueError as exc:
return [], [f"JSON invalide : {exc}"]
if not isinstance(data, list):
return [], ["KANBANTE_RULES doit être un tableau JSON de règles"]
rules: list[dict] = []
errors: list[str] = []
for i, item in enumerate(data):
label = f"règle #{i + 1}"
if not isinstance(item, dict):
errors.append(f"{label} : pas un objet")
continue
name = str(item.get("name") or label)
when = item.get("when")
then = item.get("then")
if not isinstance(when, dict) or not isinstance(then, dict):
errors.append(f"{name} : 'when' et 'then' (objets) sont requis")
continue
bad_when = set(when) - CONDITION_KEYS
bad_then = set(then) - ACTION_KEYS
if bad_when:
errors.append(f"{name} : conditions inconnues {sorted(bad_when)}")
continue
if bad_then:
errors.append(
f"{name} : actions inconnues {sorted(bad_then)} "
"(fermer/déplacer une carte par règle est interdit en v1)"
)
continue
if "column" in when and when["column"] not in KANBAN_COLUMNS:
errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue
if "min_age_days" in when and (
not isinstance(when["min_age_days"], int) or when["min_age_days"] < 0
):
errors.append(f"{name} : min_age_days doit être un entier ≥ 0")
continue
if "wip_over" in when and (
not isinstance(when["wip_over"], int) or when["wip_over"] < 1
):
errors.append(f"{name} : wip_over doit être un entier ≥ 1")
continue
if "add_label" in then and (
not isinstance(then["add_label"], str) or not then["add_label"].strip()
):
errors.append(f"{name} : add_label doit être un nom d'étiquette")
continue
if not then:
errors.append(f"{name} : aucune action")
continue
rules.append({"name": name, "when": when, "then": then})
return rules, errors
def matching_cards(
rule: dict,
columns: dict[str, list[dict]],
ages: dict[int, dict],
) -> list[tuple[dict, str]]:
"""Cards of `columns` (merged board) matched by the rule."""
when = rule["when"]
targets = [when["column"]] if "column" in when else list(columns)
matches: list[tuple[dict, str]] = []
for column in targets:
cards = columns.get(column, [])
if "wip_over" in when and len(cards) <= when["wip_over"]:
continue
for issue in cards:
if "label" in when and when["label"] not in [
(lab or {}).get("name") for lab in issue.get("labels") or []
]:
continue
if "min_age_days" in when:
age = ages.get(issue["number"]) or {}
if (age.get("days") or 0) < when["min_age_days"]:
continue
matches.append((issue, column))
return matches
+116
View File
@@ -0,0 +1,116 @@
"""Background snapshot refresher — chantier B (0.22.0).
The expensive part of Kanbante is the fan-out of Gitea list
calls behind the portfolio pages (repo index, then issues and
milestones for every active project). Since 0.19.0 those calls
are parallel and stale-while-revalidate, which fixed repeat
loads, but the FIRST page after a restart still paid seconds.
The refresher pays that cost instead of the user: every
KANBANTE_SNAPSHOT_INTERVAL seconds it force-refreshes the hot
keys (repo index, starred, issues + milestones of active
repos). Every fetch write-throughs to the SQLite `snapshots`
table, and the client cache is hydrated from that table at
startup — so pages read local data from the very first request
and only ever wait on Gitea when no snapshot exists yet.
Freshness contract, shown in the UI: Gitea-side data may lag
by up to one interval; a Gitea webhook refreshes the touched
repo immediately (see refresh_repo_snapshots); anything the
user does locally (board journal) is always instant.
"""
import asyncio
import datetime
import logging
from app import models
from app.config import get_settings
logger = logging.getLogger("kanbante.snapshots")
_REFRESH_CONCURRENCY = 8
async def refresh_repo_snapshots(gitea, owner: str, repo: str) -> None:
"""Refresh one repo's hot keys (webhooks call this).
Both issue states are refreshed: most pages read "all", but
Focus/Triage/Mes issues read "open" — a snapshot set missing
either leaves a slow first hit on the table.
"""
try:
await gitea.refresh_repo_issues(owner, repo, "all")
await gitea.refresh_repo_issues(owner, repo, "open")
await gitea.refresh_milestones(owner, repo)
except Exception as exc:
logger.debug("repo snapshot refresh failed for %s/%s: %s", owner, repo, exc)
async def refresh_snapshots(gitea, classify_repos) -> int:
"""One full refresh cycle. Returns the active-repo count."""
repos = await gitea.refresh_search_repos()
try:
await gitea.refresh_starred()
except Exception as exc:
logger.debug("starred refresh failed: %s", exc)
active, _inactive, _archived = classify_repos(repos)
semaphore = asyncio.Semaphore(_REFRESH_CONCURRENCY)
async def _one(repo: dict) -> None:
owner, _, name = repo["full_name"].partition("/")
async with semaphore:
await refresh_repo_snapshots(gitea, owner, name)
await asyncio.gather(*(_one(repo) for repo in active))
logger.info("snapshot refresh complete (%d active repos)", len(active))
return len(active)
async def snapshot_loop(gitea, classify_repos) -> None:
"""Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages,
the loop only has to keep the snapshots warm.
"""
interval = get_settings().snapshot_interval
if interval <= 0:
logger.info("snapshot refresher disabled (interval <= 0)")
return
await asyncio.sleep(10)
while True:
try:
await refresh_snapshots(gitea, classify_repos)
except Exception as exc:
logger.warning("snapshot refresh cycle failed: %s", exc)
await asyncio.sleep(interval)
def freshness_label() -> str | None:
"""« à l'instant » / « il y a N min » from the freshest snapshot.
Honest freshness display (chantier B contract): the sidebar
shows how old the Gitea data on screen can be.
"""
try:
latest = models.latest_snapshot_at()
except Exception:
return None
if not latest:
return None
try:
fetched = datetime.datetime.strptime(
latest, "%Y-%m-%d %H:%M:%S"
).replace(tzinfo=datetime.timezone.utc)
except ValueError:
return None
age = (
datetime.datetime.now(datetime.timezone.utc) - fetched
).total_seconds()
if age < 90:
return "à l'instant"
if age < 3600:
return f"il y a {int(age // 60)} min"
if age < 86400:
return f"il y a {int(age // 3600)} h"
return f"il y a {int(age // 86400)} j"
+79
View File
@@ -0,0 +1,79 @@
"""Sprint story (roadmap #4): the milestone's tale, written by the journal.
Nobody writes the end-of-milestone report by hand. Kanbante's
event journal already knows what shipped, how long it took and
what dragged — this module just turns that into clean markdown,
ready to archive in ObsiGate.
"""
def format_sprint_markdown(
repo: str,
milestone_title: str,
due_on: str | None,
period_label: str,
delivered: list[dict],
carried: list[dict],
counts: dict,
avg_cycle_days: float | None,
) -> str:
lines = [f"# 📖 Récit de sprint — {repo} · {milestone_title}", ""]
lines.append(f"*Période : {period_label}*")
if due_on:
lines.append(f"*Échéance du milestone : {due_on[:10]}*")
lines.append("")
lines.append(f"## ✅ Livré ({len(delivered)})")
if delivered:
for item in delivered:
cycle = (
f" — ⏱️ {item['cycle_days']} j de cycle"
if item.get("cycle_days") is not None
else ""
)
lines.append(
f"- [#{item['number']}]({item.get('html_url', '')}) "
f"{item.get('title', '')}{cycle}".rstrip()
)
else:
lines.append("- Rien n'a été fermé dans ce milestone pour l'instant.")
lines.append("")
slowest = sorted(
(d for d in delivered if d.get("cycle_days") is not None),
key=lambda d: -d["cycle_days"],
)[:3]
if slowest:
lines.append("## 🐌 Les plus lentes")
for item in slowest:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— {item['cycle_days']} j de cycle"
)
lines.append("")
lines.append(f"## ↩️ Reporté ({len(carried)})")
if carried:
for item in carried:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— encore en *{item.get('column_label', 'backlog')}*"
)
else:
lines.append("- Rien : tout le milestone est livré. 🎉")
lines.append("")
lines.append("## 📈 En chiffres")
lines.append(f"- Issues du milestone : {len(delivered) + len(carried)}")
lines.append(f"- Fermées : {len(delivered)}")
if avg_cycle_days is not None:
lines.append(f"- Temps de cycle moyen : {avg_cycle_days} j")
lines.append(f"- Déplacements de cartes : {counts.get('moved', 0)}")
lines.append(f"- Commentaires : {counts.get('commented', 0)}")
lines.append(f"- Réouvertures : {counts.get('reopened', 0)}")
lines.append("")
lines.append(
"*Récit généré par Kanbante à partir de son journal d'événements "
"et des issues Gitea du milestone.*"
)
return "\n".join(lines)
+37
View File
@@ -0,0 +1,37 @@
<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 %}
{% if issue._code %}
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</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 %}
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
{# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ─────────
Barre latérale unique pour TOUTES les pages applicatives :
desktop = barre fixe repliable en rail d'icônes (état mémorisé),
mobile = tiroir hors-canvas ouvert par le burger de la topbar.
NE PAS dupliquer ce markup dans les pages.
Page active : `active_nav` (déduite du gabarit dans render()).
Fil d'Ariane : `owner`+`repo` (board) ou `full_name` (projet).
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
{% set nav_groups = [
("principal", "Principal", [
("home", "/", "🏠", "Projets"),
("focus", "/focus", "🎯", "Focus"),
("today", "/today", "🌅", "Aujourd'hui"),
("my", "/my", "👤", "Mes issues"),
]),
("rituels", "Rituels", [
("briefing", "/briefing", "📊", "Briefing"),
("standup", "/standup", "🗣️", "Standup"),
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
("activity", "/activity", "📰", "Activité"),
]),
("outils", "Outils", [
("search", "/search", "🔍", "Recherche"),
("capture", "/capture", "⚡", "Capture"),
("wall", "/wall", "🖥️", "Wallboard"),
]),
] %}
{% set active_group = namespace(key="principal") %}
{% for gkey, group, links in nav_groups %}
{% for key, href, icon, label in links %}
{% if active_nav == key %}{% set active_group.key = gkey %}{% endif %}
{% endfor %}
{% endfor %}
<script>
document.body.classList.add('with-sidebar');
try {
if (localStorage.getItem('kanbante-sidebar') === 'collapsed') {
document.body.classList.add('sidebar-collapsed');
}
/* Accordéon exclusif (0.22.0) : une seule section ouverte.
La section de la page courante prime, sinon celle
mémorisée, sinon « principal ». */
localStorage.removeItem('kanbante-nav-closed');
var openGroup = {{ active_group.key | tojson }};
var stored = localStorage.getItem('kanbante-nav-open');
var groups = ['principal', 'rituels', 'pilotage', 'outils'];
if ({{ (active_nav != "") | tojson }}) {
localStorage.setItem('kanbante-nav-open', openGroup);
} else if (stored === '') {
openGroup = '';
} else if (stored !== null && groups.indexOf(stored) >= 0) {
openGroup = stored;
}
groups.forEach(function (k) {
if (k !== openGroup) document.body.classList.add('nav-closed-' + k);
});
} catch (e) { /* stockage indisponible : état par défaut */ }
</script>
<aside class="sidebar" id="sidebar" aria-label="Navigation principale">
<div class="side-head">
<a href="/" class="side-logo" aria-label="Kanbante — accueil">
<span class="logo-mark">📋</span>
<span class="side-label gradient-text">Kanbante</span>
</a>
<button type="button" class="side-search palette-trigger" title="Recherche & commandes (Ctrl+K)" aria-label="Recherche et commandes (Ctrl+K)">🔍</button>
<button type="button" class="side-collapse" id="side-collapse" title="Replier ou déplier le menu" aria-label="Replier ou déplier le menu"><span class="side-collapse-ico">«</span></button>
</div>
<nav class="side-nav">
{% for gkey, group, links in nav_groups %}
<div class="side-section" data-group="{{ gkey }}">
<button type="button" class="side-group-btn" data-group-btn="{{ gkey }}" aria-expanded="true">
<span>{{ group }}</span>
<span class="side-chevron" aria-hidden="true">▾</span>
</button>
<div class="side-acc">
<div class="side-acc-inner">
{% for key, href, icon, label in links %}
<a href="{{ href }}" class="side-link{% if active_nav == key %} active{% endif %}" title="{{ label }}">
<span class="side-ico">{{ icon }}</span>
<span class="side-label">{{ label }}</span>
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</nav>
<div class="side-foot">
{% if data_freshness %}
<span class="side-fresh side-label" title="Dernière actualisation des données Gitea (instantanés d'arrière-plan)">🕐 {{ data_freshness }}</span>
{% endif %}
<span class="side-version side-label">v{{ version }}</span>
{% if auth_enabled %}
<a href="/logout" class="side-logout" title="Se déconnecter">⏻<span class="side-label"> Sortir</span></a>
{% endif %}
</div>
</aside>
<div class="side-backdrop" id="side-backdrop" hidden></div>
<header class="app-header topbar">
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Ouvrir le menu">☰</button>
{% if owner is defined and repo is defined %}
<span class="crumb"><a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
{% elif full_name is defined %}
<span class="crumb"><a href="/board/{{ full_name }}">{{ full_name }}</a></span>
{% endif %}
<div class="header-actions">
{% if repo_info is defined and repo_info and repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
{% endif %}
<button type="button" class="nav-pill palette-trigger topbar-search" title="Recherche et commandes (Ctrl+K)">🔍</button>
</div>
<script src="/static/js/nav.js" defer></script>
</header>
+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>
+4 -6
View File
@@ -8,12 +8,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 📰 Activité</h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
</nav>
</header>
{% include "_header.html" %}
<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>
@@ -48,5 +43,8 @@
<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>
+87 -69
View File
@@ -6,37 +6,53 @@
<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>
<a href="/activity" class="btn-sm">📰 Activité</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
{% include "_header.html" %}
{% 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 %}
</nav>
</header>
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
{% if milestone_forecast %}
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
{% endif %}
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
</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>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
@@ -46,7 +62,7 @@
<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] }}">
@@ -56,60 +72,62 @@
{% 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>
{% 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>
{% 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>
@@ -121,6 +139,11 @@
<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-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
@@ -138,14 +161,9 @@
<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>
<script src="/static/js/today.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
{% include "_header.html" %}
<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>
+96
View File
@@ -0,0 +1,96 @@
<!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>
{% include "_header.html" %}
<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
<span class="repo-picker">
<input type="text" name="repo_full_name" id="capture-repo" value="{{ capture_repo }}" placeholder="Projets/kanbante" required autocomplete="off" role="combobox" aria-expanded="false" aria-controls="repo-listbox" aria-autocomplete="list">
<span class="repo-list" id="repo-listbox" role="listbox" hidden></span>
</span>
<span class="repo-hint" id="repo-hint" hidden>⚠️ Aucun dépôt de ce nom dans Gitea — vérifie l'orthographe, la capture échouera sinon.</span>
<span class="repo-tip">Astuce : tape un début, <kbd>Tab</kbd> complète segment par segment (<code>pr</code> → <code>Projets/</code> → <code>kan</code> → <code>kanbante</code>), ↑↓ + Entrée choisissent dans la liste.</span>
</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>
<script src="/static/js/repo-picker.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+214 -34
View File
@@ -9,55 +9,142 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<a href="/activity" class="btn-sm">📰 Activité</a>
<span class="badge">{{ active_repos|length }} actifs</span>
<span class="badge secondary">{{ archived_repos|length }} archivés</span>
</nav>
</header>
{% include "_header.html" %}
<main x-data="{ tab: 'active', search: '' }">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
<div class="tabs">
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
🟢 Actifs ({{ active_repos|length }})
</button>
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
💤 Inactifs ({{ inactive_repos|length }})
</button>
<button :class="{ active: tab === 'archived' }" @click="tab = 'archived'">
🗄️ Archivés ({{ archived_repos|length }})
</button>
<!-- En-tête de page (refonte 0.20.0) : titre sobre, bande KPI
unifiée, puis UNE barre d'outils (dans <main>) qui regroupe
onglets, filtre, tri et vue — fini les rangées éparses. -->
<section class="page-head">
<div class="page-head-row">
<div class="page-title">
<h1>Projets</h1>
<p class="page-sub">Vue d'ensemble de vos dépôts Gitea — Gitea reste la source de vérité.</p>
</div>
<div class="kpi-strip" aria-label="Indicateurs des 30 derniers jours">
<div class="kpi"><span class="kpi-value">{{ active_repos|length }}</span><span class="kpi-label">Projets actifs</span></div>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.closed }}</span><span class="kpi-label">Issues fermées · 30 j</span></a>
<a class="kpi" href="/activity"><span class="kpi-value">{{ metrics.moved }}</span><span class="kpi-label">Déplacements · 30 j</span></a>
<div class="kpi"><span class="kpi-value">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }}<span class="kpi-unit"> j</span>{% else %}—{% endif %}</span><span class="kpi-label">Temps de cycle moyen</span></div>
</div>
</div>
</section>
<!-- 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">
<script>
// État du dashboard (#21/#22) : la vue grille/liste survit au
// rechargement (localStorage), lue avant qu'Alpine démarre.
function dashState() {
var view = 'grid';
try { view = localStorage.getItem('kanbante-dash-view') || 'grid'; } catch (e) { /* défaut */ }
return {
tab: 'active', search: '', view: view,
setView: function (v) {
this.view = v;
try { localStorage.setItem('kanbante-dash-view', v); } catch (e) { /* non-fatal */ }
}
};
}
</script>
<main x-data="dashState()">
<!-- Barre d'outils unique (0.20.0) : onglets segmentés,
filtre, tri/regroupement (#21) et vue (#22) alignés.
Onglets : "actif" = fenêtre glissante ; "archivé" =
le vrai drapeau Gitea (repo.archived). Le filtre lit
des data-* échappés par Jinja, jamais des littéraux JS. -->
<div class="dash-toolbar">
<div class="seg-tabs" role="tablist" aria-label="État des projets">
<button type="button" role="tab" :class="{ active: tab === 'active' }" @click="tab = 'active'">Actifs <span class="seg-count">{{ active_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">Inactifs <span class="seg-count">{{ inactive_repos|length }}</span></button>
<button type="button" role="tab" :class="{ active: tab === 'archived' }" @click="tab = 'archived'">Archivés <span class="seg-count">{{ archived_repos|length }}</span></button>
</div>
<div class="search-bar toolbar-search">
<input type="text" id="project-search" placeholder="Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
<option value="name">Nom A → Z</option>
</select>
</label>
<label class="dash-tool dash-tool-check">
<input type="checkbox" id="dash-group"> Grouper par propriétaire
</label>
<div class="dash-view-toggle" role="group" aria-label="Mode d'affichage">
<button type="button" :class="{ active: view === 'grid' }" @click="setView('grid')">▦ Grille</button>
<button type="button" :class="{ active: view === 'list' }" @click="setView('list')">☰ Liste</button>
</div>
</div>
{% macro project_card(repo, archived=False) %}
{% set full = repo.full_name %}
<div class="project-card{% if archived %} archived{% endif %}"
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
{% if repo.health_dot %}
<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="Santé du flux — {{ repo.health_dot.tooltip }}" aria-label="Santé du flux : {{ repo.health_dot.tooltip }}"></a>
{% endif %}
<span class="date">{{ repo.updated_at[:10] }}</span>
{% if not archived %}
<button type="button" class="star-btn" data-full="{{ full }}" data-starred="{{ 1 if repo.starred else 0 }}" title="{{ 'Désépingler (étoile Gitea)' if repo.starred else 'Épingler (étoile Gitea)' }}" aria-label="Épingler {{ repo.name }}">{{ '⭐' if repo.starred else '☆' }}</button>
<div class="card-menu-wrap">
<button type="button" class="card-menu-btn" data-menu-btn aria-haspopup="true" aria-expanded="false" title="Actions rapides pour {{ repo.name }}" aria-label="Actions rapides pour {{ repo.name }}">⋯</button>
<div class="card-menu" hidden>
<a href="/board/{{ full }}">📋 Board</a>
<a href="/capture?repo={{ full|urlencode }}">⚡ Capturer ici</a>
<a href="/triage/{{ full }}">📥 Triage du backlog</a>
<a href="/health/{{ full }}">🩺 Santé</a>
<a href="/sprint/{{ full }}">📖 Récit de sprint</a>
{% if repo.html_url %}
<a href="{{ repo.html_url }}" target="_blank" rel="noopener">🔗 Gitea ↗</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
<a href="/project/{{ 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 %}
{% if repo.next_milestone %}
{% set ms = repo.next_milestone %}
<div class="ms-block">
<div class="ms-line">
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% if ms.eta_late %} — ⚠️ après l'échéance{% endif %}{% endif %}</div>
{% endif %}
</div>
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues</span>
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
<a href="/board/{{ full }}" class="card-board-link">📋 Board</a>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
{% 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] }}">
@@ -68,24 +155,117 @@
</div>
{% endif %}
</div>
{% if repo.activity %}
<div class="activity-line">
{% if repo.activity.closed or repo.activity.moved %}
📈 {{ repo.activity.closed }} fermée(s) · {{ repo.activity.moved }} déplacement(s) cette semaine
{% elif repo.activity.idle_days is not none and repo.activity.idle_days > 7 %}
💤 aucune activité kanban depuis {{ repo.activity.idle_days }} j
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% macro project_row(repo, archived=False) %}
{% set full = repo.full_name %}
{% set st = stats.get(full, {}) %}
<tr data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login|lower }}"
data-open="{{ repo.open_issues_count or 0 }}"
data-inflight="{{ (st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0)) }}"
data-updated="{{ repo.updated_at }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
<td><a href="/project/{{ full }}"><strong>{{ repo.name }}</strong></a></td>
<td>{{ repo.owner.login }}</td>
<td>{{ repo.open_issues_count or 0 }}</td>
<td>{{ st.get('in_progress', 0) + st.get('qa', 0) + st.get('review', 0) }}</td>
<td>{% if repo.next_milestone %}🎯 {{ repo.next_milestone.title }}{% if repo.next_milestone.late %} ⚠️{% endif %}{% else %}—{% endif %}</td>
<td>{% if repo.health_dot %}<a href="/health/{{ full }}" class="health-dot {{ repo.health_dot.level }}" title="{{ repo.health_dot.tooltip }}" aria-label="Santé : {{ repo.health_dot.tooltip }}"></a>{% else %}—{% endif %}</td>
<td>{{ repo.updated_at[:10] }}</td>
</tr>
{% endmacro %}
{% macro project_table(repos, tab_name, archived=False) %}
<div x-show="tab === '{{ tab_name }}'">
<table class="proj-table">
<thead>
<tr>
<th data-sort="name">Projet</th>
<th data-sort="owner">Propriétaire</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="inflight">En vol</th>
<th>Prochain jalon</th>
<th>Santé</th>
<th data-sort="updated" data-first-dir="desc">Dernière activité</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}{{ project_row(repo, archived) }}{% endfor %}
</tbody>
</table>
{% if not repos %}
<div class="empty-state">Aucun projet dans cette vue.</div>
{% endif %}
</div>
{% endmacro %}
<div x-show="view === 'grid'">
<!-- Épinglés (#20) : les dépôts étoilés dans Gitea, hors onglets. -->
<section id="pinned-section"{% if not pinned_repos %} hidden{% endif %}>
<h3 class="pinned-title">⭐ Épinglés</h3>
<div class="project-grid" id="pinned-grid">
{% for repo in pinned_repos %}{{ project_card(repo) }}{% endfor %}
</div>
</section>
<div x-show="tab === 'active'" class="project-grid" id="grid-active">
{% for repo in active_listed %}{{ project_card(repo) }}{% endfor %}
{% if not active_listed and not pinned_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 %}
<div x-show="tab === 'inactive'" class="project-grid" id="grid-inactive">
{% for repo in inactive_listed %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_listed %}
<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">
<div x-show="tab === 'archived'" class="project-grid" id="grid-archived">
{% 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>
</div>
<!-- Vue liste dense (#22) : mêmes onglets, mêmes données. -->
<div x-show="view === 'list'">
{{ project_table(active_repos, 'active') }}
{{ project_table(inactive_repos, 'inactive') }}
{{ project_table(archived_repos, 'archived', archived=True) }}
</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>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
<script src="/static/js/stars.js" defer></script>
<script src="/static/js/dashboard.js" defer></script>
<script src="/static/js/tablesort.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>
+78
View File
@@ -0,0 +1,78 @@
<!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>
{% include "_header.html" %}
<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>
+1 -3
View File
@@ -7,9 +7,7 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
</header>
{% include "_header.html" %}
<main class="error-main">
<div class="error-box">
<h2>⚠️ Quelque chose s'est mal passé</h2>
+4 -6
View File
@@ -8,12 +8,7 @@
<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>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
@@ -58,5 +53,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>
+120
View File
@@ -0,0 +1,120 @@
<!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>Prévisions — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
<p class="forecast-summary">
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
</p>
<div class="search-bar forecast-filter">
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
</div>
</section>
{% if rows %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="project">Projet</th>
<th data-sort="title">Jalon</th>
<th data-sort="due">Échéance</th>
<th>Progression</th>
<th>ETA</th>
<th data-sort="gap" data-first-dir="desc">Écart</th>
<th data-sort="throughput">Débit / sem.</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
<td>{{ row.due_label }}</td>
<td>
{% if row.total %}
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
{% else %}—{% endif %}
</td>
<td>{{ row.eta or '—' }}</td>
<td>
{% if row.verdict == 'late' %}
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
{% elif row.verdict == 'ontime' %}
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
{% elif row.verdict == 'nodue' %}
<span class="verdict">sans échéance</span>
{% else %}
<span class="verdict">pas d'ETA</span>
{% endif %}
</td>
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
{% endif %}
<section class="briefing-card">
<h2>📈 Débit par projet</h2>
{% for project in projects %}
<div class="forecast-flex">
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
{% for bar in project.bars %}
{% set px = (bar.h * 0.42)|round|int %}
<rect x="{{ loop.index0 * 21 }}" y="{{ 44 - px }}" width="15" height="{{ px if px > 0 else 1 }}" rx="2">
<title>{{ bar.label }} : {{ bar.count }} fermée(s)</title>
</rect>
{% endfor %}
</svg>
<div class="forecast-nums">
{% if project.stats %}
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
{% else %}
<p>Débit : <strong>pas assez d'historique</strong>.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% else %}
<p>Aucun projet actif.</p>
{% endfor %}
</section>
</main>
<footer>
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
<script>
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
var q = e.target.value.toLowerCase();
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
});
});
</script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé du flux — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🩺 Santé du flux</h1>
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
</section>
{% if projects %}
<table class="proj-table pilot-table">
<thead>
<tr>
<th data-sort="severity">Santé</th>
<th data-sort="project">Projet</th>
<th data-sort="open">Ouvertes</th>
<th data-sort="zombies" data-first-dir="desc">🧟 Zombies</th>
<th data-sort="slowest">🐌 Goulot</th>
<th></th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
data-zombies="{{ project.zombies|length }}"
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
<td>{{ project.open_count }}</td>
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
<td>
{% if project.slowest %}
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
</td>
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<section class="briefing-card"><p>Aucun projet actif.</p></section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
<script src="/static/js/tablesort.js" defer></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!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>Santé — {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if project_names %}
<div class="health-switch-bar">
<label for="health-switch">🩺 Projet :</label>
<select id="health-switch">
{% for name in project_names %}
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
{% endfor %}
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
</select>
<a href="/health" class="health-back">← tous les projets</a>
</div>
{% endif %}
{% if health is none %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if health.slowest %}
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/health.js" defer></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
<!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>Connexion — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="login-body">
<main class="login-wrap">
<section class="login-card">
<div class="login-brand">
<span class="login-mark" aria-hidden="true">📋</span>
<h1>Kanbante</h1>
<p class="login-sub">Pilotage de projets, adossé à votre Gitea</p>
</div>
{% if error %}
<p class="login-error" role="alert">⚠️ {{ error }}</p>
{% endif %}
<form method="post" action="/login" class="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="next" value="{{ next }}">
<label>Nom d'utilisateur
<input type="text" name="username" autocomplete="username" required autofocus>
</label>
<label>Mot de passe
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
<!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>
{% include "_header.html" %}
<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>
+129
View File
@@ -0,0 +1,129 @@
<!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>{{ full_name }} — Projet · 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>
{% include "_header.html" %}
<main class="project-main">
<div class="proj-head">
<div>
<h1>📦 {{ repo_info.name }}</h1>
<p class="proj-sub">{{ owner }} · {{ repo_info.description or 'Aucune description dans Gitea.' }}</p>
</div>
{% if health_dot %}
<a href="/health/{{ full_name }}" class="health-chip" title="{{ health_dot.tooltip }}">
<span class="health-dot {{ health_dot.level }}"></span> Santé du flux
</a>
{% endif %}
</div>
<div class="proj-actions">
<a href="/board/{{ full_name }}" class="nav-pill proj-primary">📋 Ouvrir le board</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/triage/{{ full_name }}" class="nav-pill">📥 Triage</a>
<a href="/health/{{ full_name }}" class="nav-pill">🩺 Santé</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall?repo={{ full_name }}" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</div>
<div class="proj-grid">
<section class="proj-panel">
<h2>🎯 Jalons ouverts</h2>
{% if milestones %}
{% for ms in milestones %}
<div class="proj-ms">
<div class="ms-line">
<a href="/board/{{ full_name }}?milestone={{ ms.title|urlencode }}"><strong>{{ ms.title }}</strong></a>
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
</div>
{% if ms.total %}
<div class="progress ms-progress"><span style="width: {{ ms.pct }}%"></span></div>
<div class="ms-meta">{{ ms.closed }}/{{ ms.total }} terminées{% if ms.eta_label %} · 🔮 {{ ms.eta_label }}{% endif %}</div>
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
{% endif %}
</section>
<section class="proj-panel">
<h2>🩺 Santé du flux</h2>
{% if health %}
<ul class="proj-list">
<li>📂 <strong>{{ health.open_count }}</strong> cartes ouvertes</li>
{% if health.slowest %}
<li>🐢 Goulot : <strong>{{ COLUMN_LABELS[health.slowest.column] }}</strong> — séjour médian {{ health.slowest.median_days }} j</li>
{% else %}
<li>🐢 Pas encore de goulot mesurable (historique trop court).</li>
{% endif %}
<li>🧟 Zombies (≥ {{ health.zombie_days }} j sans activité) : <strong>{{ health.zombies|length }}</strong></li>
</ul>
{% if health.zombies %}
<ul class="proj-list proj-zombies">
{% for z in health.zombies[:5] %}
<li><a href="{{ z.html_url }}" target="_blank" rel="noopener">#{{ z.number }}</a> {{ z.title }} <span class="proj-dim">· {{ z.column_label }} · {{ z.days_inactive }} j</span></li>
{% endfor %}
</ul>
{% endif %}
<p><a href="/health/{{ full_name }}">Détail de la santé →</a></p>
{% else %}
<p class="proj-empty">Santé indisponible pour le moment.</p>
{% endif %}
</section>
<section class="proj-panel">
<h2>🔮 Prévisions</h2>
{% if throughput %}
<ul class="proj-list">
<li>Débit : <strong>{{ throughput.avg }}</strong> fermetures / semaine en moyenne (min {{ throughput.min }} · max {{ throughput.max }}, 4 dernières semaines complètes)</li>
<li>Issues ouvertes : <strong>{{ repo_info.open_issues_count or 0 }}</strong></li>
</ul>
{% else %}
<p class="proj-empty">Pas assez d'historique de fermetures pour prévoir — le journal se remplit au fil des cartes terminées.</p>
{% endif %}
<p><a href="/forecast">Toutes les prévisions →</a></p>
</section>
<section class="proj-panel">
<h2>🕘 Activité récente</h2>
{% if events %}
<ul class="proj-list">
{% for e in events %}
<li>{{ EVENT_ICONS.get(e.event_type, '•') }} <a href="/board/{{ full_name }}">#{{ e.issue_number }}</a> — {{ e.event_type }}{% if e.to_column %} → {{ COLUMN_LABELS.get(e.to_column, e.to_column) }}{% endif %} <span class="proj-dim">· {{ e.created_at[:16].replace('T', ' ') }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="proj-empty">Aucun événement du journal pour ce projet pour le moment.</p>
{% endif %}
<p><a href="/activity">Journal complet →</a></p>
</section>
<section class="proj-panel proj-notes">
<h2>📝 Notes du projet</h2>
{% if notes %}
<div class="proj-notes-body">{{ notes }}</div>
{% else %}
<p class="proj-empty">Aucune note. Les notes s'écrivent depuis le panneau « 🗒️ Notes » du board et vivent avec le projet.</p>
{% endif %}
<p><a href="/board/{{ full_name }}">Ouvrir le board pour éditer →</a></p>
</section>
</div>
</main>
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!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>Règles d'automatisation — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>⚙️ Règles d'automatisation</h1>
<p>Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
{% if not configured %}
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
<pre class="briefing-text">[{"name": "Relance révision",
"when": {"column": "review", "min_age_days": 3},
"then": {"notify": true}},
{"name": "Urgent qui vieillit",
"when": {"column": "in_progress", "min_age_days": 7},
"then": {"add_label": "urgent"}}]</pre>
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
{% endif %}
{% for err in errors %}
<p class="chip-warn">⚠️ {{ err }}</p>
{% endfor %}
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
<span id="rules-run-result" class="notes-hint"></span></p>
</section>
{% if rules %}
<section class="briefing-card">
<h2>📜 Règles actives ({{ rules|length }})</h2>
<ul class="forecast-milestones">
{% for rule in rules %}
<li><strong>{{ rule.name }}</strong> — quand
{% for k, v in rule.when.items() %}<code>{{ k }} = {{ v }}</code>{% if not loop.last %}, {% endif %}{% endfor %}
→ {% for k, v in rule.then.items() %}<code>{{ k }}{% if v is not boolean %} = {{ v }}{% endif %}</code>{% if not loop.last %}, {% endif %}{% endfor %}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if last_run %}
<section class="briefing-card">
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
{% if last_run.actions %}
<ul class="forecast-milestones">
{% for action in last_run.actions %}
<li><strong>{{ action.rule }}</strong> : {{ action.repo }} #{{ action.number }} ({{ action.column }}) — {{ action.did|join(', ') }}</li>
{% endfor %}
</ul>
{% else %}
<p>Aucune action déclenchée.</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'automatisation maison, près des données, jamais destructive</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script>
document.getElementById('rules-run').addEventListener('click', async () => {
const out = document.getElementById('rules-run-result');
out.textContent = 'Évaluation en cours…';
const csrf = document.querySelector('meta[name="csrf-token"]').content;
const data = new FormData();
data.append('csrf_token', csrf);
try {
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
const body = await r.json();
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
setTimeout(() => location.reload(), 700);
} catch (e) {
out.textContent = '❌ ' + e.message;
}
});
</script>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
<!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>
{% include "_header.html" %}
<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>
+66
View File
@@ -0,0 +1,66 @@
<!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>Récit de sprint — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
{% if markdown %}
<section class="briefing-card">
<h1>📖 {{ milestone_title }}</h1>
<p>
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
</p>
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
</section>
<section class="briefing-card sprint-render" id="sprint-render"></section>
<script>
const SPRINT_MD = {{ markdown|tojson }};
document.getElementById('sprint-render').innerHTML =
DOMPurify.sanitize(marked.parse(SPRINT_MD));
document.getElementById('sprint-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(SPRINT_MD);
document.getElementById('sprint-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
document.getElementById('sprint-download').addEventListener('click', () => {
const blob = new Blob([SPRINT_MD], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'recit-{{ full_name|replace('/', '-') }}-{{ milestone_param|urlencode }}.md';
a.click();
URL.revokeObjectURL(a.href);
});
</script>
{% else %}
<section class="briefing-card">
<h1>📖 Récit de sprint — {{ full_name }}</h1>
<p>Choisis un milestone pour générer son récit :</p>
<ul>
{% for m in milestones %}
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
{% else %}
<li>Aucun milestone dans ce projet.</li>
{% endfor %}
</ul>
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · le récit est écrit par le journal, pas par la mémoire des gens</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!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>Standup — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>🗣️ Standup automatique</h1>
<p>Écrit par le journal, pas par la mémoire : fermé/déplacé <strong>hier</strong>, focus <strong>aujourd'hui</strong>, cartes qui vieillissent en QA/Révision en <strong>blocages</strong>.</p>
<p>
<button type="button" class="btn-save btn-inline" id="standup-copy">📋 Copier le standup</button>
{% if include_in_briefing %}
<span class="chip-ok">Inclus dans le briefing ntfy du matin ✔</span>
{% else %}
<span class="notes-hint">Astuce : <code>KANBANTE_BRIEFING_INCLUDE_STANDUP=1</code> l'ajoute au briefing ntfy du matin.</span>
{% endif %}
</p>
</section>
<section class="briefing-card">
<pre class="briefing-text" id="standup-text">{{ text }}</pre>
</section>
</main>
<footer>
<span>Kanbante v{{ version }} · zéro saisie, toujours factuel, daté</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script>
document.getElementById('standup-copy').addEventListener('click', async () => {
const text = document.getElementById('standup-text').textContent;
try {
await navigator.clipboard.writeText(text);
document.getElementById('standup-copy').textContent = '✅ Copié !';
} catch (e) { alert('Copie impossible : ' + e); }
});
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!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>Aujourd'hui — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="today-main">
<h1 class="today-title">🌅 {{ date_label }}</h1>
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
{% if picks %}
<section class="today-picks">
{% for pick in picks %}
<article class="today-card{% if pick.state == 'closed' %} done{% endif %}">
<div class="today-card-top">
<span class="today-card-repo">{{ pick.repo }}</span>
<span class="today-card-col">{{ pick.column_label }}</span>
</div>
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
<div class="today-card-actions">
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
</div>
</article>
{% endfor %}
</section>
{% else %}
<section class="briefing-card">
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
</section>
{% endif %}
{% if recap %}
<section class="briefing-card">
<h2>🌆 Ça a bougé aujourd'hui</h2>
<ul>
{% for event in recap %}
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if carry_over %}
<section class="briefing-card">
<h2>↩️ À reprendre d'hier</h2>
<ul class="today-list">
{% for pick in carry_over %}
<li>
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if candidates and picks|length < 3 %}
<section class="briefing-card">
<h2>🎯 Candidates (encours actuel)</h2>
<ul class="today-list">
{% for card in candidates %}
<li>
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · le focus du jour se réinitialise chaque matin — les cartes non terminées reviennent en proposition</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/today.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
<!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>Triage — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<section class="briefing-card">
<h1>📥 Mode Triage</h1>
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
</section>
{% if projects %}
<div class="triage-projects">
{% for p in projects %}
<a class="triage-project" href="/triage/{{ p.full_name }}">
<span class="triage-project-name">📂 {{ p.full_name }}</span>
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
</a>
{% endfor %}
</div>
{% else %}
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
<!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>Triage {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
{% include "_header.html" %}
<main class="triage-main" data-repo="{{ full_name }}">
<div class="triage-head">
<h1>📥 Triage — {{ full_name }}</h1>
<div class="triage-progress-wrap">
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
<span class="triage-progress-label" id="triage-progress-label"></span>
</div>
</div>
<section class="triage-card" id="triage-card" aria-live="polite">
<div class="triage-card-top">
<span class="triage-card-num" id="tc-num"></span>
<span class="triage-card-date" id="tc-date"></span>
<a class="triage-card-link" id="tc-link" href="#" target="_blank" rel="noopener">Ouvrir dans Gitea ↗</a>
</div>
<h2 class="triage-card-title" id="tc-title"></h2>
<div class="triage-card-labels" id="tc-labels"></div>
<p class="triage-card-excerpt" id="tc-excerpt"></p>
<p class="triage-card-assign" id="tc-assign"></p>
</section>
<section class="triage-done" id="triage-done" hidden>
<h2>🎉 Session terminée</h2>
<ul class="triage-summary" id="triage-summary"></ul>
<p>
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
</p>
</section>
<div class="triage-status" id="triage-status" role="status"></div>
<section class="triage-keys" id="triage-keys">
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
</section>
<div class="triage-labels" id="triage-labels" hidden>
<div class="triage-labels-head">
<strong>🏷️ Étiquettes de la carte</strong>
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
</div>
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement</span>
</footer>
<script id="triage-queue" type="application/json">{{ queue_json|safe }}</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/triage.js" defer></script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!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 %}
{% if slide.cards_total > slide.cards|length %}
<li class="wall-card wall-more">… +{{ slide.cards_total - slide.cards|length }} autres cartes en vol</li>
{% endif %}
</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>
{% if not single and slides|length > 1 %}
<nav class="wall-nav" aria-label="Navigation des projets">
<button type="button" class="wall-nav-btn" id="wall-prev" title="Projet précédent (←)">❮</button>
<div class="wall-dots" id="wall-dots"></div>
<button type="button" class="wall-nav-btn" id="wall-next" title="Projet suivant (→)">❯</button>
<div class="wall-paused" id="wall-paused" hidden>⏸️ Rotation en pause — Espace pour reprendre</div>
</nav>
{% endif %}
{% 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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.3.0"
version = "0.22.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+1372 -241
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();
});
})();
+304
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);
@@ -67,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);
});
}
@@ -113,6 +118,25 @@ 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;
@@ -128,6 +152,175 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// 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;
@@ -176,12 +369,68 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number);
} catch (err) {
document.getElementById('detail-title').textContent = 'Erreur de chargement';
showToast('❌ Détail indisponible : ' + err.message, true);
}
}
async function loadCodeLinks(repoFull, number) {
const box = document.getElementById('detail-code');
if (!box) return;
box.innerHTML = '';
const [owner, repo] = repoFull.split('/', 2);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) return;
const parts = [];
if ((data.branches || []).length) {
parts.push('🌿 Branches : ' + data.branches.map(b => `<code>${b}</code>`).join(', '));
}
for (const pr of data.pulls || []) {
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
}
for (const c of (data.commits || []).slice(0, 8)) {
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
}
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
const createBtn = document.getElementById('branch-create');
if (createBtn) {
createBtn.addEventListener('click', async () => {
const name = (document.getElementById('branch-name') || {}).value || '';
const form = new FormData();
form.append('name', name);
form.append('csrf_token', csrfToken);
createBtn.disabled = true;
try {
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
const body = await r2.json().catch(() => ({}));
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
showToast(body.exists
? `🌿 La branche ${body.branch} existait déjà`
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
loadCodeLinks(repoFull, number);
} catch (err) {
createBtn.disabled = false;
showToast('❌ Branche non créée : ' + err.message, true);
}
});
}
} catch (err) { /* le code est un bonus, jamais bloquant */ }
}
function fillDetail(issue, comments) {
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
@@ -205,6 +454,7 @@ document.addEventListener('DOMContentLoaded', () => {
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 || '#';
@@ -216,6 +466,60 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
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';
+173
View File
@@ -0,0 +1,173 @@
/**
* Kanbante — dashboard display tools (issues #21, #22, #23).
* Sort + group-by-owner for the card grids, sortable dense tables,
* and the per-card quick-actions menu. Preferences live in
* localStorage; the server markup stays the single source.
*/
(function () {
var LS_SORT = 'kanbante-dash-sort';
var LS_GROUP = 'kanbante-dash-group';
var LS_OWNERS = 'kanbante-dash-owners-closed';
function lsGet(key, fallback) {
try {
var v = localStorage.getItem(key);
return v === null ? fallback : v;
} catch (e) { return fallback; }
}
function lsSet(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* non-fatal */ }
}
/* ── Tri des grilles de cartes ─────────────────────────── */
var sortSelect = document.getElementById('dash-sort');
// 0.20.1 : la grille des Épinglés est incluse — l'exclure
// (choix de la 0.18.0) donnait l'impression que le tri et le
// regroupement « ne faisaient rien », la section épinglée
// étant celle qu'on regarde en premier.
var grids = ['pinned-grid', 'grid-active', 'grid-inactive', 'grid-archived']
.map(function (id) { return document.getElementById(id); })
.filter(Boolean);
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
}
function sortCards(container, mode) {
var cards = Array.prototype.slice.call(
container.querySelectorAll('.project-card'));
if (mode === 'default' || !cards.length) return;
cards.sort(function (a, b) {
var ka = cardKey(a, mode), kb = cardKey(b, mode);
if (mode === 'updated' || mode === 'activity') {
// Comparaison en instants réels (les fuseaux des
// dates Gitea varient, -04:00/-05:00) ; une carte
// sans date coule en bas du tri décroissant.
var ta = Date.parse(ka), tb = Date.parse(kb);
if (isNaN(ta) && isNaN(tb)) return 0;
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return tb - ta;
}
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
});
cards.forEach(function (c) { container.appendChild(c); });
}
function applySort() {
var mode = sortSelect ? sortSelect.value : 'default';
lsSet(LS_SORT, mode);
grids.forEach(function (grid) {
if (groupToggle && groupToggle.checked) {
grid.querySelectorAll('.owner-grid').forEach(function (og) {
sortCards(og, mode);
});
} else {
sortCards(grid, mode);
}
});
}
/* ── Regroupement par propriétaire ─────────────────────── */
var groupToggle = document.getElementById('dash-group');
function closedOwners() {
try { return JSON.parse(lsGet(LS_OWNERS, '[]')); } catch (e) { return []; }
}
function setOwnerClosed(owner, closed) {
var list = closedOwners().filter(function (o) { return o !== owner; });
if (closed) list.push(owner);
lsSet(LS_OWNERS, JSON.stringify(list));
}
function groupGrid(grid) {
var cards = Array.prototype.slice.call(
grid.querySelectorAll(':scope > .project-card'));
if (!cards.length) return;
var byOwner = {};
cards.forEach(function (c) {
var o = c.getAttribute('data-owner') || '—';
(byOwner[o] = byOwner[o] || []).push(c);
});
Object.keys(byOwner).sort().forEach(function (owner) {
var wrap = document.createElement('div');
wrap.className = 'owner-group';
var head = document.createElement('button');
head.type = 'button';
head.className = 'owner-head';
var closed = closedOwners().indexOf(owner) >= 0;
head.setAttribute('aria-expanded', closed ? 'false' : 'true');
head.innerHTML = '<span>🗂️ ' + owner + ' (' + byOwner[owner].length + ')</span><span class="owner-chevron">▾</span>';
var body = document.createElement('div');
body.className = 'project-grid owner-grid';
if (closed) body.hidden = true;
head.addEventListener('click', function () {
body.hidden = !body.hidden;
head.setAttribute('aria-expanded', body.hidden ? 'false' : 'true');
setOwnerClosed(owner, body.hidden);
});
byOwner[owner].forEach(function (c) { body.appendChild(c); });
wrap.appendChild(head);
wrap.appendChild(body);
grid.appendChild(wrap);
});
}
function ungroupGrid(grid) {
grid.querySelectorAll('.owner-group').forEach(function (wrap) {
var body = wrap.querySelector('.owner-grid');
Array.prototype.slice.call(body.querySelectorAll('.project-card'))
.forEach(function (c) { grid.appendChild(c); });
wrap.remove();
});
}
function applyGrouping() {
var on = !!(groupToggle && groupToggle.checked);
lsSet(LS_GROUP, on ? '1' : '0');
grids.forEach(function (grid) {
ungroupGrid(grid);
if (on) groupGrid(grid);
});
applySort();
}
if (sortSelect) {
sortSelect.value = lsGet(LS_SORT, 'default');
sortSelect.addEventListener('change', applySort);
}
if (groupToggle) {
groupToggle.checked = lsGet(LS_GROUP, '0') === '1';
groupToggle.addEventListener('change', applyGrouping);
}
applyGrouping(); // applique regroupement + tri mémorisés
/* ── Menu ⋯ d'actions rapides (#23) ────────────────────── */
function closeMenus() {
document.querySelectorAll('.card-menu').forEach(function (m) {
m.hidden = true;
});
document.querySelectorAll('[data-menu-btn]').forEach(function (b) {
b.setAttribute('aria-expanded', 'false');
});
}
document.querySelectorAll('[data-menu-btn]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var menu = btn.parentElement.querySelector('.card-menu');
var willOpen = menu.hidden;
closeMenus();
if (willOpen) {
menu.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
});
});
document.addEventListener('click', closeMenus);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeMenus();
});
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
partagé avec /forecast et /health (0.19.0). */
})();
+54
View File
@@ -0,0 +1,54 @@
/**
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
* Close/backlog reuse /api/move; the nudge reuses the comments API.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
/* Project switcher (0.19.0): jump between projects without
going back to the health index. */
var switcher = document.getElementById('health-switch');
if (switcher) {
switcher.addEventListener('change', function () {
window.location.href = '/health/' + switcher.value;
});
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-zombie]');
if (!btn) return;
var repo = btn.getAttribute('data-repo');
var number = btn.getAttribute('data-number');
var column = btn.getAttribute('data-column');
var action = btn.getAttribute('data-zombie');
var parts = repo.split('/');
btn.disabled = true;
var done = function () { location.reload(); };
var fail = function (err) { btn.disabled = false; alert(err.message); };
if (action === 'comment') {
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
}).then(done).catch(fail);
} else {
post('/api/move', {
repo_full_name: repo,
issue_number: number,
from_column: column,
to_column: action === 'close' ? 'done' : 'backlog',
to_order: number
}).then(done).catch(fail);
}
});
})();
+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);
})();
+75
View File
@@ -0,0 +1,75 @@
/**
* Kanbante — sidebar behaviour (issue #16, 0.16.0 layout).
* Desktop: collapse to an icon rail (persisted in localStorage).
* Mobile: the same sidebar becomes an off-canvas drawer, toggled
* by the topbar burger, closed by backdrop / Escape / navigation.
*/
(function () {
var burger = document.getElementById('nav-burger');
var backdrop = document.getElementById('side-backdrop');
var collapseBtn = document.getElementById('side-collapse');
function closeDrawer() {
document.body.classList.remove('sidebar-open');
if (backdrop) backdrop.hidden = true;
if (burger) {
burger.setAttribute('aria-expanded', 'false');
burger.textContent = '☰';
}
}
if (burger) {
burger.addEventListener('click', function () {
var open = document.body.classList.toggle('sidebar-open');
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
burger.textContent = open ? '✕' : '☰';
if (backdrop) backdrop.hidden = !open;
});
}
if (backdrop) backdrop.addEventListener('click', closeDrawer);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
window.addEventListener('resize', function () {
if (window.innerWidth > 900) closeDrawer();
});
if (collapseBtn) {
collapseBtn.addEventListener('click', function () {
var collapsed = document.body.classList.toggle('sidebar-collapsed');
try {
localStorage.setItem('kanbante-sidebar', collapsed ? 'collapsed' : 'open');
} catch (e) { /* private mode: state lasts for the page only */ }
});
}
/* Accordéon EXCLUSIF (0.22.0) : une seule section ouverte
à la fois. Ouvrir une section ferme les autres ; cliquer
la section ouverte la referme. L'état initial vient du
script d'amorce de _header.html (section de la page
courante ou choix mémorisé dans kanbante-nav-open). */
var groupBtns = document.querySelectorAll('[data-group-btn]');
function syncGroupBtn(btn) {
var closed = document.body.classList.contains(
'nav-closed-' + btn.getAttribute('data-group-btn'));
btn.setAttribute('aria-expanded', closed ? 'false' : 'true');
}
function setOpenGroup(openKey) {
groupBtns.forEach(function (btn) {
var key = btn.getAttribute('data-group-btn');
document.body.classList.toggle('nav-closed-' + key, key !== openKey);
syncGroupBtn(btn);
});
try {
localStorage.setItem('kanbante-nav-open', openKey);
} catch (e) { /* non-fatal */ }
}
groupBtns.forEach(function (btn) {
syncGroupBtn(btn);
btn.addEventListener('click', function () {
var key = btn.getAttribute('data-group-btn');
var isOpen = !document.body.classList.contains('nav-closed-' + key);
setOpenGroup(isOpen ? '' : key);
});
});
})();
+184
View File
@@ -0,0 +1,184 @@
/**
* 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: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
{ 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: 'Mode Triage — vider un backlog au clavier', href: '/triage' },
{ 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(); });
});
})();
+245
View File
@@ -0,0 +1,245 @@
/**
* Kanbante — sélecteur de dépôt de la Capture éclair (0.21.0).
*
* Le champ Dépôt devient intelligent :
* - suggestions des dépôts réels dès la frappe (données /api/repos,
* épinglés et récents d'abord) ;
* - correspondance sur les deux segments : « kan » suffit pour
* trouver Projets/kanbante, « pr/kan » aussi ;
* - complétion Tab segment par segment, comme un shell :
* « pr » Tab → « Projets/ », puis « kan » Tab → « kanbante » ;
* - ↑/↓ + Entrée, clic souris, Échap ;
* - avertissement doux si le nom final n'existe pas dans Gitea.
*/
(function () {
var input = document.getElementById('capture-repo');
if (!input) return;
var list = document.getElementById('repo-listbox');
var hint = document.getElementById('repo-hint');
var repos = null;
var candidates = [];
var activeIndex = -1;
var isOpen = false;
function load() {
if (repos) return Promise.resolve(repos);
return fetch('/api/repos', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) { repos = data.repos || []; return repos; })
.catch(function () { repos = []; return repos; });
}
/* ── Correspondance ──────────────────────────────────── */
function matchRepos(value) {
var raw = value.trim().toLowerCase();
if (!raw) return repos.slice(0, 12);
var parts = raw.split('/').map(function (s) { return s.trim(); });
var scored = [];
repos.forEach(function (repo) {
var full = repo.full_name.toLowerCase();
var slash = full.indexOf('/');
var owner = full.slice(0, slash);
var name = full.slice(slash + 1);
var score = null;
if (parts.length === 1) {
var q = parts[0];
if (!q) score = 4;
else if (name.indexOf(q) === 0) score = 0;
else if (owner.indexOf(q) === 0) score = 1;
else if (name.indexOf(q) > -1) score = 2;
else if (full.indexOf(q) > -1) score = 3;
} else {
if (owner.indexOf(parts[0]) === 0 && name.indexOf(parts[1]) === 0) score = 0;
else if (owner.indexOf(parts[0]) > -1 && name.indexOf(parts[1]) > -1) score = 1;
}
if (score !== null) scored.push({ repo: repo, score: score });
});
scored.sort(function (a, b) {
if (a.score !== b.score) return a.score - b.score;
if (a.repo.starred !== b.repo.starred) return a.repo.starred ? -1 : 1;
return (b.repo.updated_at || '').localeCompare(a.repo.updated_at || '');
});
return scored.slice(0, 12).map(function (s) { return s.repo; });
}
/* ── Rendu / ouverture ───────────────────────────────── */
function highlight() {
Array.prototype.forEach.call(list.children, function (item, i) {
item.classList.toggle('active', i === activeIndex);
});
}
function render() {
list.innerHTML = '';
candidates.forEach(function (repo, i) {
var item = document.createElement('span');
item.className = 'repo-item';
item.setAttribute('role', 'option');
var star = repo.starred ? '⭐ ' : '';
var date = repo.updated_at ? repo.updated_at.slice(0, 10) : '';
var nameEl = document.createElement('span');
nameEl.className = 'repo-item-name';
nameEl.textContent = star + repo.full_name;
var metaEl = document.createElement('span');
metaEl.className = 'repo-item-meta';
metaEl.textContent = (repo.archived ? 'archivé · ' : '') + date;
item.appendChild(nameEl);
item.appendChild(metaEl);
item.addEventListener('mousedown', function (e) {
e.preventDefault();
choose(repo);
});
item.addEventListener('mousemove', function () {
if (activeIndex !== i) { activeIndex = i; highlight(); }
});
list.appendChild(item);
});
highlight();
list.hidden = !isOpen || candidates.length === 0;
input.setAttribute('aria-expanded', isOpen && candidates.length ? 'true' : 'false');
}
function refresh(openList) {
if (!repos) return;
candidates = matchRepos(input.value);
if (activeIndex >= candidates.length) activeIndex = candidates.length - 1;
isOpen = openList;
render();
validate();
}
function close() {
isOpen = false;
activeIndex = -1;
render();
}
function choose(repo) {
input.value = repo.full_name;
close();
validate();
input.focus();
}
function validate() {
if (!hint || !repos) return;
var value = input.value.trim().toLowerCase();
var exact = !value || repos.some(function (r) {
return r.full_name.toLowerCase() === value;
});
hint.hidden = exact;
input.classList.toggle('repo-unknown', !exact);
}
/* ── Complétion Tab, segment par segment ─────────────── */
function commonPrefix(strings) {
if (!strings.length) return '';
var prefix = strings[0];
for (var i = 1; i < strings.length; i++) {
while (strings[i].indexOf(prefix) !== 0) {
prefix = prefix.slice(0, -1);
if (!prefix) return '';
}
}
return prefix;
}
function completeSegment() {
if (!repos) return false;
var value = input.value;
var slash = value.lastIndexOf('/');
var head = slash >= 0 ? value.slice(0, slash + 1) : '';
var segment = slash >= 0 ? value.slice(slash + 1) : value;
var pool = [];
if (slash < 0) {
var owners = {};
repos.forEach(function (r) {
var o = r.full_name.split('/')[0];
if (o.toLowerCase().indexOf(segment.toLowerCase()) === 0) owners[o] = true;
});
pool = Object.keys(owners);
} else {
var ownerPart = value.slice(0, slash).toLowerCase();
repos.forEach(function (r) {
var parts = r.full_name.split('/');
if (parts[0].toLowerCase() === ownerPart &&
parts[1].toLowerCase().indexOf(segment.toLowerCase()) === 0) {
pool.push(parts[1]);
}
});
}
if (!pool.length) return false;
var lcp = commonPrefix(pool);
if (lcp.length > segment.length &&
lcp.toLowerCase().indexOf(segment.toLowerCase()) === 0) {
var completed = head + lcp;
// La complétion tombe sur un nom d'organisation exact :
// poser le séparateur dans le même Tab (comme un shell
// qui complète un répertoire) — le geste « pr » Tab
// donne « Projets/ », prêt pour le segment du dépôt.
if (slash < 0 && pool.indexOf(lcp) !== -1) completed += '/';
input.value = completed;
refresh(true);
return true;
}
// Segment d'organisation déjà complet et sans ambiguïté :
// poser le séparateur et passer au segment du dépôt.
if (slash < 0 && pool.length === 1 &&
lcp.toLowerCase() === segment.toLowerCase()) {
input.value = head + lcp + '/';
refresh(true);
return true;
}
return false;
}
/* ── Événements ──────────────────────────────────────── */
input.addEventListener('focus', function () {
load().then(function () { refresh(true); });
});
input.addEventListener('input', function () {
activeIndex = -1;
load().then(function () { refresh(true); });
});
input.addEventListener('blur', function () {
close();
validate();
});
input.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
if (completeSegment()) {
e.preventDefault();
} else {
close();
}
return;
}
if (e.key === 'Escape') {
close();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
load().then(function () {
if (!isOpen) { refresh(true); activeIndex = 0; highlight(); return; }
var delta = e.key === 'ArrowDown' ? 1 : -1;
activeIndex = Math.max(0, Math.min(candidates.length - 1, activeIndex + delta));
highlight();
});
return;
}
if (e.key === 'Enter') {
if (isOpen && activeIndex >= 0 && candidates[activeIndex]) {
e.preventDefault();
choose(candidates[activeIndex]);
} else if (isOpen && candidates.length === 1) {
e.preventDefault();
choose(candidates[0]);
}
}
});
// Si le champ est pré-rempli (lien ?repo= ou défaut), valider
// dès que la liste est connue.
if (input.value) load().then(validate);
})();
+56
View File
@@ -0,0 +1,56 @@
/**
* Kanbante — pin projects via Gitea stars (issue #20).
* The star lives in Gitea (source of truth); this only toggles it
* through the API and moves the card between the pinned section
* and its home tab grid, without a page reload.
*/
(function () {
var pinnedSection = document.getElementById('pinned-section');
var pinnedGrid = document.getElementById('pinned-grid');
function refreshPinnedVisibility() {
if (!pinnedSection || !pinnedGrid) return;
pinnedSection.hidden = pinnedGrid.querySelectorAll('.project-card').length === 0;
}
document.querySelectorAll('.star-btn').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault();
e.stopPropagation();
var full = btn.getAttribute('data-full');
var parts = full.split('/');
var want = btn.getAttribute('data-starred') !== '1';
btn.disabled = true;
try {
var fd = new FormData();
fd.append('starred', want ? '1' : '0');
fd.append('csrf_token', window.KANBANTE_CSRF || '');
var res = await fetch('/api/projects/' + parts[0] + '/' + parts[1] + '/star', {
method: 'POST', body: fd,
});
var data = await res.json();
if (!data.ok) throw new Error(data.error || 'échec');
btn.setAttribute('data-starred', want ? '1' : '0');
btn.textContent = want ? '⭐' : '☆';
btn.title = want ? 'Désépingler (étoile Gitea)' : 'Épingler (étoile Gitea)';
var card = btn.closest('.project-card');
if (card) {
if (want && pinnedGrid) {
pinnedGrid.appendChild(card);
} else {
var home = document.getElementById('grid-' + (card.getAttribute('data-home') || 'active'));
if (home) home.appendChild(card);
}
refreshPinnedVisibility();
}
} catch (err) {
btn.textContent = '⚠️';
setTimeout(function () {
btn.textContent = btn.getAttribute('data-starred') === '1' ? '⭐' : '☆';
}, 1600);
} finally {
btn.disabled = false;
}
});
});
})();
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
* entre le dashboard, /forecast et /health (0.19.0).
* Tri client par colonne : les lignes portent leurs valeurs dans
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
* trie dans un sens puis dans l'autre.
*/
(function () {
document.querySelectorAll('.proj-table').forEach(function (table) {
var tbody = table.querySelector('tbody');
if (!tbody) return;
table.querySelectorAll('th[data-sort]').forEach(function (th) {
th.classList.add('sortable');
th.addEventListener('click', function () {
var key = th.getAttribute('data-sort');
// Premier clic : sens par défaut de la colonne
// (data-first-dir="desc" pour les dates d'activité —
// on veut le plus récent d'abord) ; ensuite on alterne.
var dir = th.getAttribute('data-dir')
? (th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc')
: (th.getAttribute('data-first-dir') || 'asc');
table.querySelectorAll('th').forEach(function (h) {
h.removeAttribute('data-dir');
});
th.setAttribute('data-dir', dir);
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var va = a.getAttribute('data-' + key) || '';
var vb = b.getAttribute('data-' + key) || '';
var cmp;
// Numérique SEULEMENT si la valeur entière est
// un nombre. L'ancien parseFloat lisait « 2026 »
// dans les dates ISO : toutes les lignes d'une
// même année se retrouvaient à égalité et le tri
// par date partait dans un ordre arbitraire
// (bug rapporté par Bruno, corrigé en 0.20.1).
if (/^-?\d+(\.\d+)?$/.test(va) && /^-?\d+(\.\d+)?$/.test(vb)) {
cmp = parseFloat(va) - parseFloat(vb);
} else {
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
}
return dir === 'asc' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
});
})();
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — Focus of the day (issue #5): pin/unpin from /today and,
* via a delegated handler, the 📌 button on board cards.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
function post(url, repo, number) {
var data = new FormData();
data.append('repo_full_name', repo);
data.append('issue_number', number);
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
// Capture phase: a 📌 click on a board card must be swallowed
// before board.js sees it (it would open the detail drawer).
document.addEventListener('click', function (e) {
var pinBtn = e.target.closest('[data-pin]');
var unpinBtn = e.target.closest('[data-unpin]');
var cardPin = e.target.closest('.pin-btn');
if (pinBtn) {
post('/api/today/pin', pinBtn.getAttribute('data-repo'), pinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (unpinBtn) {
post('/api/today/unpin', unpinBtn.getAttribute('data-repo'), unpinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (cardPin) {
// Board card 📌: never open the detail drawer for this click.
e.stopPropagation();
e.preventDefault();
var card = cardPin.closest('.kanban-card');
post('/api/today/pin', card.getAttribute('data-repo'), card.getAttribute('data-issue'))
.then(function () {
cardPin.textContent = '📌✓';
cardPin.title = 'Épinglée au Focus du jour (/today)';
})
.catch(function (err) { alert(err.message); });
}
}, true);
})();
+236
View File
@@ -0,0 +1,236 @@
/**
* Kanbante — Triage session (issue #11).
*
* One backlog card at a time, decided from the keyboard:
* 1-5 move to a column (5 = done, closes), L labels panel,
* A assign me, X close, Enter next (skip), Z undo last move.
* Decisions hit the same APIs as the board (/api/move, label
* toggles, assign-self) — the queue lives entirely client-side.
*/
(function () {
var main = document.querySelector('.triage-main');
if (!main) return;
var repo = main.getAttribute('data-repo');
var parts = repo.split('/');
var owner = parts[0], name = parts.slice(1).join('/');
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var queue = JSON.parse(document.getElementById('triage-queue').textContent || '[]');
var idx = 0;
var history = []; // {number, to} for undoable moves
var counts = {}; // decision tally for the summary
var busy = false;
var el = function (id) { return document.getElementById(id); };
var cardEl = el('triage-card'), doneEl = el('triage-done');
var statusEl = el('triage-status'), labelsPanel = el('triage-labels');
function current() { return queue[idx] || null; }
function status(msg, isError) {
statusEl.textContent = msg || '';
statusEl.style.color = isError ? '#ff7b72' : '';
}
function render() {
var card = current();
var total = queue.length;
el('triage-bar').style.width = total ? Math.round(100 * idx / total) + '%' : '100%';
el('triage-progress-label').textContent = total
? 'Carte ' + Math.min(idx + 1, total) + ' / ' + total
: '0 carte';
if (!card) {
cardEl.hidden = true;
el('triage-keys').hidden = true;
doneEl.hidden = false;
var summary = el('triage-summary');
summary.innerHTML = '';
var labels = {
todo: '📋 À faire', in_progress: '🚧 En cours', qa: '🧪 QA',
review: '👀 Révision', done: '✅ Terminé / fermée',
skip: '⏭️ Passées (restent au backlog)',
};
Object.keys(labels).forEach(function (key) {
if (counts[key]) {
var li = document.createElement('li');
li.textContent = labels[key] + ' : ' + counts[key];
summary.appendChild(li);
}
});
if (!Object.keys(counts).length) {
var li = document.createElement('li');
li.textContent = 'Aucune décision prise.';
summary.appendChild(li);
}
return;
}
cardEl.hidden = false;
doneEl.hidden = true;
el('tc-num').textContent = '#' + card.number;
el('tc-date').textContent = card.created_at
? 'créée le ' + card.created_at + (card.age_days != null ? ' · ⏳ ' + card.age_days + ' j' : '')
: '';
el('tc-link').href = card.html_url || '#';
el('tc-title').textContent = card.title;
el('tc-excerpt').textContent = card.excerpt || '(aucune description)';
var labelsBox = el('tc-labels');
labelsBox.innerHTML = '';
(card.labels || []).forEach(function (l) {
var chip = document.createElement('span');
chip.className = 'label-tag';
chip.style.background = '#' + l.color + '30';
chip.style.color = '#' + l.color;
chip.textContent = l.name;
labelsBox.appendChild(chip);
});
el('tc-assign').textContent = (card.assignees && card.assignees.length)
? '👤 Assignée à : ' + card.assignees.join(', ')
: '';
status('');
}
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
function advance(kind) {
counts[kind] = (counts[kind] || 0) + 1;
idx += 1;
render();
}
function decide(toColumn) {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Envoi…');
post('/api/move', {
repo_full_name: repo, issue_number: card.number,
from_column: 'backlog', to_column: toColumn, from_order: '', to_order: '',
}).then(function () {
history.push({ number: card.number, to: toColumn });
busy = false;
advance(toColumn);
}).catch(function (err) {
busy = false;
status('❌ ' + err.message + ' — réessaie.', true);
});
}
function undo() {
var last = history.pop();
var card = current();
if (!last || busy) return;
// The undone card is the one just before the current index.
var target = queue[idx - 1];
if (!target || target.number !== last.number) return;
busy = true;
status('⏳ Annulation…');
post('/api/move', {
repo_full_name: repo, issue_number: last.number,
from_column: last.to, to_column: 'backlog', from_order: '', to_order: '',
}).then(function () {
counts[last.to] = Math.max(0, (counts[last.to] || 1) - 1);
if (!counts[last.to]) delete counts[last.to];
idx -= 1;
busy = false;
render();
}).catch(function (err) {
busy = false;
history.push(last);
status('❌ Annulation impossible : ' + err.message, true);
});
void card;
}
function assignSelf() {
var card = current();
if (!card || busy) return;
busy = true;
status('⏳ Assignation…');
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/assign-self', {})
.then(function (body) {
card.assignees = body.assignees || card.assignees;
busy = false;
render();
status('👤 Assignée à : ' + (body.assignees || []).join(', '));
})
.catch(function (err) { busy = false; status('❌ ' + err.message, true); });
}
function openLabels() {
var card = current();
if (!card) return;
labelsPanel.hidden = false;
var list = el('triage-labels-list');
list.textContent = 'Chargement…';
fetch('/api/repos/' + owner + '/' + name + '/labels')
.then(function (r) { return r.json(); })
.then(function (data) {
list.innerHTML = '';
var mine = (card.labels || []).map(function (l) { return l.name; });
(data.labels || []).forEach(function (label) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'triage-label-chip' + (mine.includes(label.name) ? ' on' : '');
btn.textContent = (mine.includes(label.name) ? '✓ ' : '') + label.name;
btn.addEventListener('click', function () {
var action = mine.includes(label.name) ? 'remove' : 'add';
post('/api/issues/' + owner + '/' + name + '/' + card.number + '/labels',
{ label_id: label.id, action: action })
.then(function (body) {
card.labels = body.labels || [];
render();
openLabels();
})
.catch(function (err) { status('❌ ' + err.message, true); });
});
list.appendChild(btn);
});
if (!(data.labels || []).length) list.textContent = 'Aucune étiquette dans ce dépôt.';
})
.catch(function () { list.textContent = 'Échec du chargement des étiquettes.'; });
}
function closeLabels() { labelsPanel.hidden = true; }
document.querySelectorAll('.triage-keys button').forEach(function (btn) {
btn.addEventListener('click', function () {
var act = btn.getAttribute('data-act');
if (act === 'move') decide(btn.getAttribute('data-col'));
else if (act === 'close') decide('done');
else if (act === 'next') { if (current()) advance('skip'); }
else if (act === 'undo') undo();
else if (act === 'assign') assignSelf();
else if (act === 'labels') { labelsPanel.hidden ? openLabels() : closeLabels(); }
});
});
el('triage-labels-close').addEventListener('click', closeLabels);
document.addEventListener('keydown', function (e) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
var tag = (document.activeElement || {}).tagName || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (!labelsPanel.hidden) {
if (e.key === 'Escape' || e.key.toLowerCase() === 'l') { e.preventDefault(); closeLabels(); }
return;
}
var colByKey = { '1': 'todo', '2': 'in_progress', '3': 'qa', '4': 'review', '5': 'done' };
if (colByKey[e.key]) { e.preventDefault(); decide(colByKey[e.key]); }
else if (e.key === 'Enter') { e.preventDefault(); if (current()) advance('skip'); }
else if (e.key.toLowerCase() === 'x') { e.preventDefault(); decide('done'); }
else if (e.key.toLowerCase() === 'l') { e.preventDefault(); openLabels(); }
else if (e.key.toLowerCase() === 'a') { e.preventDefault(); assignSelf(); }
else if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); }
});
render();
})();
+112
View File
@@ -0,0 +1,112 @@
/**
* Kanbante — Wallboard: rotation, manual navigation, clock, live.
*
* One slide per active project, rotating every 30 s. The viewer can
* take over at any time: ←/→ keys (or the on-screen ❮ ❯ buttons and
* dots) switch projects immediately and pause the rotation for
* 90 s; Space toggles a hard pause. The current position survives
* the live/safety reloads via sessionStorage, so the wall no longer
* appears stuck on the first project after every refresh.
*/
(function () {
var slides = Array.from(document.querySelectorAll('.wall-slide'));
var ROTATE_MS = 30000;
var MANUAL_PAUSE_MS = 90000;
var STORAGE_KEY = 'kanbante-wall-index';
var current = 0;
var paused = false;
var pausedUntil = 0;
var pausedBadge = document.getElementById('wall-paused');
var dotsBox = document.getElementById('wall-dots');
function updateBadge() {
if (pausedBadge) {
pausedBadge.hidden = !(paused || Date.now() < pausedUntil);
}
}
function updateDots() {
if (!dotsBox) return;
Array.from(dotsBox.children).forEach(function (dot, i) {
dot.classList.toggle('on', i === current);
});
}
function show(index, manual) {
if (!slides.length) return;
slides[current].classList.remove('active');
current = ((index % slides.length) + slides.length) % slides.length;
slides[current].classList.add('active');
try { sessionStorage.setItem(STORAGE_KEY, String(current)); } catch (e) { /* private mode */ }
updateDots();
if (manual) {
pausedUntil = Date.now() + MANUAL_PAUSE_MS;
updateBadge();
}
}
// Restore the position kept across reloads.
if (slides.length > 1) {
var saved = 0;
try { saved = parseInt(sessionStorage.getItem(STORAGE_KEY) || '0', 10) || 0; } catch (e) { saved = 0; }
if (saved) show(saved, false);
// Dots (one per project, clickable).
if (dotsBox) {
slides.forEach(function (_slide, i) {
var dot = document.createElement('button');
dot.type = 'button';
dot.className = 'wall-dot';
dot.title = 'Projet ' + (i + 1);
dot.setAttribute('aria-label', 'Afficher le projet ' + (i + 1));
dot.addEventListener('click', function () { show(i, true); });
dotsBox.appendChild(dot);
});
updateDots();
}
var prev = document.getElementById('wall-prev');
var next = document.getElementById('wall-next');
if (prev) prev.addEventListener('click', function () { show(current - 1, true); });
if (next) next.addEventListener('click', function () { show(current + 1, true); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); show(current + 1, true); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(current - 1, true); }
else if (e.key === ' ') {
e.preventDefault();
paused = !paused;
if (!paused) pausedUntil = 0;
updateBadge();
}
});
setInterval(function () {
if (!paused && Date.now() >= pausedUntil) show(current + 1, false);
}, ROTATE_MS);
setInterval(updateBadge, 5000);
}
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: board activity reloads the wall. The hub emits NAMED
// "gitea" events — onmessage would never fire (fixed in 0.11.0).
try {
var source = new EventSource('/api/stream');
source.addEventListener('gitea', function () { location.reload(); });
} catch (err) {
console.warn('Wallboard live stream unavailable:', err);
}
// Safety net.
setTimeout(function () { location.reload(); }, 5 * 60 * 1000);
})();
+173 -6
View File
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
"owner": {"login": "Projets"},
"archived": archived,
"html_url": f"http://gitea.test/Projets/{name}",
"default_branch": "main",
}
@@ -47,17 +48,18 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
"title": title,
"body": f"Corps de l'issue {number} en **markdown**.",
"state": state,
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": None,
"assignees": [],
"milestone": 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
@@ -66,11 +68,13 @@ class FakeGitea:
def __init__(self) -> None:
now = datetime.now(timezone.utc)
self.starred = set()
self.repos = [
_repo("demo", now - timedelta(days=1)),
_repo("vieux", now - timedelta(days=800)),
_repo("archive", now - timedelta(days=1), archived=True),
]
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
self.issues = {
"Projets/demo": [
_issue(1, "Tâche un"),
@@ -79,7 +83,12 @@ 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.assignee_changes: list[tuple] = []
self.created: list[tuple] = []
self.comments: dict[tuple, list[dict]] = {
("Projets/demo", 1): [
@@ -105,6 +114,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")
@@ -150,6 +190,125 @@ class FakeGitea:
self.posted_comments.append((owner, repo, number, body))
return comment
def invalidate_cache(self):
self.cache_invalidations += 1
async def get_labels(self, owner, repo):
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
async def create_label(self, owner, repo, name, color):
self._label_seq += 1
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
self.label_ops.append(("create", f"{owner}/{repo}", name))
return label
async def ensure_label(self, owner, repo, name, color):
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
if name in existing:
return existing[name]
return await self.create_label(owner, repo, name, color)
async def add_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
label = next(
l for l in self.repo_labels[f"{owner}/{repo}"].values()
if l["id"] == label_id
)
if label not in issue["labels"]:
issue["labels"].append(label)
self.label_ops.append(("add", number, label["name"]))
async def remove_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
removed = [l for l in issue["labels"] if l["id"] == label_id]
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
for label in removed:
self.label_ops.append(("remove", number, label["name"]))
async def list_branches(self, owner, repo):
return [{"name": n} for n in self.branches]
async def create_branch(self, owner, repo, new_branch, old_branch):
if new_branch in self.branches:
raise RuntimeError("branch exists")
self.branches.append(new_branch)
return {"name": new_branch}
async def list_pulls(self, owner, repo, state="all"):
return [
{
"number": 16, "title": "Corrige la tâche un",
"body": "Fixes #1", "state": "open", "merged": False,
"html_url": "http://gitea.test/pr/16",
"head": {"ref": "feature/1-tache-un"},
},
{
"number": 17, "title": "Focus", "body": "",
"state": "closed", "merged": True,
"html_url": "http://gitea.test/pr/17",
"head": {"ref": "issue-5-focus"},
},
]
async def list_commits(self, owner, repo, limit=75):
return [
{
"sha": "abcdef1234567890",
"html_url": "http://gitea.test/c/abcdef1",
"commit": {
"message": "Début de la tâche #1\n\nDétails",
"author": {"date": "2026-10-10T10:00:00Z"},
},
},
{
"sha": "1234567abcdef",
"html_url": "http://gitea.test/c/1234567",
"commit": {
"message": "Commit sans rapport",
"author": {"date": "2026-10-09T10:00:00Z"},
},
},
]
# ── Snapshots (chantier B, 0.22.0) : le faux reflète
# l'interface du vrai client.
def hydrate_from_snapshots(self):
return 0
def latest_snapshot_at(self):
return None
async def refresh_search_repos(self):
return await self.search_repos()
async def refresh_starred(self):
return await self.list_starred()
async def refresh_repo_issues(self, owner, repo, state="all"):
return await self.get_repo_issues(owner, repo, state=state)
async def refresh_milestones(self, owner, repo):
return await self.get_milestones(owner, repo)
async def list_starred(self):
return [r for r in self.repos if r["full_name"] in self.starred]
async def set_starred(self, owner, repo, starred):
if starred:
self.starred.add(f"{owner}/{repo}")
else:
self.starred.discard(f"{owner}/{repo}")
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)
@@ -158,6 +317,14 @@ class FakeGitea:
self.created.append((owner, repo, title))
return issue
async def set_issue_assignees(self, owner, repo, issue_number, logins):
self.assignee_changes.append((owner, repo, issue_number, list(logins)))
for issue in self.issues.get(f"{owner}/{repo}", []):
if issue["number"] == issue_number:
issue["assignees"] = [{"login": name} for name in logins]
return issue
return {}
async def set_issue_state(self, owner, repo, issue_number, state):
if self.fail_state_change:
raise httpx.HTTPError("simulated write-back failure")
+8
View File
@@ -20,9 +20,17 @@ def test_auth_required_when_configured(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
# Sans en-tête navigateur : 401 (comportement historique).
assert client.get("/").status_code == 401
# Le Basic reste accepté pour les scripts (0.22.0).
assert client.get("/", headers=_basic("bruno", "mauvais")).status_code == 401
assert client.get("/", headers=_basic("bruno", "motdepasse")).status_code == 200
# Un navigateur, lui, est envoyé vers l'écran de connexion.
r = client.get(
"/", headers={"Accept": "text/html"}, follow_redirects=False
)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Liveness stays public for Docker HEALTHCHECK.
assert client.get("/api/health").status_code == 200
+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")]
+202
View File
@@ -0,0 +1,202 @@
"""Palier 10 (0.13.0): code lié (#1), PR fusionnée (#2),
standup automatique (#6), règles d'automatisation (#8)."""
import json
from datetime import datetime, timedelta
from app import code_links as cl
from app import models
from app import rules as rules_mod
from tests.conftest import csrf_from
# ── #1 Code lié : fonctions pures ─────────────────────────────────
def test_extract_refs_and_branch_numbers():
assert cl.extract_issue_refs("Fixes #12 et rapport #3, pas #x") == {12, 3}
assert cl.extract_issue_refs("a#b #12") == {12}
assert cl.extract_issue_refs(None) == set()
assert cl.branch_issue_number("feature/12-login") == 12
assert cl.branch_issue_number("issue-7") == 7
assert cl.branch_issue_number("42") == 42
assert cl.branch_issue_number("release-2026") == 2026
assert cl.branch_issue_number("main") is None
assert cl.branch_issue_number("hotfix/no-number") is None
def test_code_endpoint(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/1/code")
assert r.status_code == 200
data = r.json()
assert data["branches"] == ["feature/1-tache-un"]
assert [p["number"] for p in data["pulls"]] == [16]
assert [c["sha"] for c in data["commits"]] == ["abcdef1"]
assert data["summary"]["pr"]["label"] == "ouverte"
board = client.get("/board/Projets/demo")
assert "PR #16 ouverte" in board.text
assert "1 commit" in board.text
# ── #2 PR fusionnée → carte Terminé ───────────────────────────────
def _pr_payload(merged=True, title="Corrige tout", body="Fixes #1",
ref="feature/1-tache-un"):
return {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"pull_request": {
"number": 16, "title": title, "body": body,
"merged": merged, "head": {"ref": ref},
},
}
def _post_webhook(client, payload, event="pull_request"):
return client.post(
"/webhooks/gitea", json=payload, headers={"X-Gitea-Event": event}
)
def test_pr_merged_moves_card_and_closes_issue(client, fake_gitea):
token = csrf_from(client)
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,
})
r = _post_webhook(client, _pr_payload())
assert r.status_code == 200
assert r.json()["handled"] == "pr-merged:#1:closed"
assert ("Projets", "demo", 1, "closed") in fake_gitea.state_changes
board = models.get_board_state("Projets/demo")
assert 1 in [c["issue_number"] for c in board["done"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "closed" and e["source"] == "webhook"
for e in events
)
def test_pr_merged_by_branch_only_and_idempotent(client, fake_gitea):
payload = _pr_payload(title="Sans référence", body="", ref="issue-5-focus")
r = _post_webhook(client, payload)
assert "#5:closed" in r.json()["handled"]
r2 = _post_webhook(client, payload) # echo: no double journal entry
closed_events = [
e for e in models.get_events(repo_full_name="Projets/demo")
if e["event_type"] == "closed" and e["issue_number"] == 5
]
assert len(closed_events) == 1
assert r2.json()["handled"] in ("pr-merged:#5:noop", "pr-merged:#5:closed")
def test_pr_not_merged_ignored(client, fake_gitea):
r = _post_webhook(client, _pr_payload(merged=False))
assert r.json()["handled"] == "ignored"
assert models.get_events(repo_full_name="Projets/demo") == []
# ── #6 Standup ────────────────────────────────────────────────────
def test_standup_from_journal(client, fake_gitea):
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
models.log_event("Projets/demo", 3, "closed", to_column="done")
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? WHERE issue_number = 3",
(yesterday,),
)
r = client.get("/api/standup")
assert r.status_code == 200
text = r.json()["text"]
assert "Standup" in text
assert "1 fermée(s)" in text
assert "Ancienne tâche" in text
page = client.get("/standup")
assert page.status_code == 200
assert "Copier le standup" in page.text
# ── #8 Règles ─────────────────────────────────────────────────────
def test_parse_rules_validation():
rules, errors = rules_mod.parse_rules(
'[{"name": "ok", "when": {"column": "review"}, "then": {"notify": true}}]'
)
assert errors == [] and rules[0]["name"] == "ok"
_r, errors = rules_mod.parse_rules("{pas json")
assert errors and "JSON invalide" in errors[0]
_r, errors = rules_mod.parse_rules(
'[{"when": {"column": "review"}, "then": {"close_issue": true}}]'
)
assert errors and "interdit" in errors[0]
_r, errors = rules_mod.parse_rules(
'[{"when": {"column": "nope"}, "then": {"notify": true}}]'
)
assert errors and "colonne inconnue" in errors[0]
def test_matching_cards_conditions():
columns = {
"review": [
{"number": 1, "labels": [{"name": "bloqué"}]},
{"number": 2, "labels": []},
],
"in_progress": [{"number": 3, "labels": []}],
}
ages = {1: {"days": 5}, 2: {"days": 1}, 3: {"days": 9}}
rule = {"name": "r", "when": {"column": "review", "min_age_days": 3},
"then": {"notify": True}}
assert [i["number"] for i, _ in rules_mod.matching_cards(rule, columns, ages)] == [1]
rule2 = {"name": "r2", "when": {"column": "review", "label": "bloqué"},
"then": {"notify": True}}
assert [i["number"] for i, _ in rules_mod.matching_cards(rule2, columns, ages)] == [1]
rule3 = {"name": "r3", "when": {"column": "review", "wip_over": 1},
"then": {"notify": True}}
assert len(rules_mod.matching_cards(rule3, columns, ages)) == 2
rule4 = {"name": "r4", "when": {"column": "in_progress", "wip_over": 3},
"then": {"notify": True}}
assert rules_mod.matching_cards(rule4, columns, ages) == []
def test_rules_run_end_to_end(client, fake_gitea, monkeypatch):
monkeypatch.setenv(
"KANBANTE_RULES",
json.dumps([{
"name": "Marque le focus",
"when": {"column": "in_progress"},
"then": {"add_label": "urgent"},
}]),
)
token = csrf_from(client)
client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 5,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "5", "csrf_token": token,
})
r = client.post("/api/rules/run", data={"csrf_token": token})
assert r.status_code == 200
actions = r.json()["summary"]["actions"]
assert len(actions) == 1
assert actions[0]["number"] == 5
assert "urgent" in actions[0]["did"][0]
detail = client.get("/api/issues/Projets/demo/5")
assert "urgent" in [l["name"] for l in detail.json()["issue"]["labels"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(e["event_type"] == "rule" for e in events)
# Second run: label already present -> no new action.
r2 = client.post("/api/rules/run", data={"csrf_token": token})
assert r2.json()["summary"]["actions"] == []
page = client.get("/rules")
assert page.status_code == 200
assert "Marque le focus" in page.text
def test_rules_page_unconfigured(client, fake_gitea):
page = client.get("/rules")
assert page.status_code == 200
assert "KANBANTE_RULES" in page.text
+159
View File
@@ -0,0 +1,159 @@
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
from datetime import datetime, timedelta
from app import code_links as cl
from app import flow
from app import models
from tests.conftest import csrf_from
# ── #3 Branche depuis la carte ────────────────────────────────────
def test_slugify_and_branch_name():
assert cl.slugify_title("Créer la branche depuis la carte") == \
"creer-la-branche-depuis-la-carte"
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
"fix-l-ete-indien-urgent"
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
assert len(cl.slugify_title("x" * 100)) == 40
assert cl.slugify_title("!!!") == "issue"
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
# La convention est reconnue par l'appariement (#1/#2).
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
def test_create_branch_endpoint(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r.status_code == 200
data = r.json()
assert data["branch"] == "issue-5-tache-focus"
assert data["from"] == "main"
assert data["moved"] is True
assert "issue-5-tache-focus" in fake_gitea.branches
board = models.get_board_state("Projets/demo")
assert 5 in [c["issue_number"] for c in board["in_progress"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "moved" and e["source"] == "branch"
for e in events
)
# Deuxième appel : la branche existe déjà, pas de doublon.
r2 = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r2.json()["exists"] is True
def test_create_branch_custom_name_and_validation(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "feature/super-truc"})
assert r.status_code == 200
assert r.json()["branch"] == "feature/super-truc"
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "mauvais nom.."})
assert r.status_code == 400
def test_code_endpoint_suggests_branch(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/3/code")
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
# ── #9 Santé du flux : fonctions pures ────────────────────────────
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return {
"repo_full_name": "Projets/demo", "issue_number": number,
"event_type": kind, "from_column": frm, "to_column": to,
"created_at": at,
}
def test_column_dwell_reconstruction():
events = [
_ev(0, "created", to="backlog"),
_ev(4, "moved", frm="backlog", to="in_progress"),
_ev(10, "closed", frm="in_progress", to="done"),
_ev(1, "created", to="backlog", number=2),
_ev(3, "moved", frm="backlog", to="review", number=2),
]
dwell = flow.column_dwell(events)
assert dwell["backlog"] == [4.0, 2.0]
assert dwell["in_progress"] == [6.0]
summary = flow.summarize_dwell(dwell)
by_col = {row["column"]: row for row in summary}
assert by_col["backlog"]["median_days"] == 3.0
assert by_col["backlog"]["count"] == 2
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
slowest = flow.slowest_column(summary)
assert slowest["column"] == "backlog"
events.append(_ev(20, "moved", frm="review", to="done", number=2))
events += [
_ev(0, "created", to="backlog", number=3),
_ev(2, "moved", frm="backlog", to="review", number=3),
_ev(19, "moved", frm="review", to="done", number=3),
]
dwell = flow.column_dwell(events)
summary = flow.summarize_dwell(dwell)
slowest = flow.slowest_column(summary)
assert slowest["column"] == "review"
assert slowest["median_days"] == 17.0
def test_last_activity():
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
latest = flow.last_activity(events)
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
# ── #9 Pages santé ────────────────────────────────────────────────
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
frm="backlog", to="review"):
models.log_event("Projets/demo", number, "created", to_column="backlog")
models.log_event("Projets/demo", number, "moved",
from_column=frm, to_column=to)
now = datetime.now()
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'created'",
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'moved'",
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
def test_health_pages_and_bottleneck(client, fake_gitea):
_seed_stay(1, 40, 10) # 30 j en backlog
_seed_stay(5, 35, 5) # 30 j en backlog aussi
page = client.get("/health/Projets/demo")
assert page.status_code == 200
assert "backlog" in page.text.lower()
index = client.get("/health")
assert index.status_code == 200
assert "Projets/demo" in index.text
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
# donc zombies au seuil par défaut de 30 j.
page = client.get("/health/Projets/demo")
assert "Tâche un" in page.text
assert 'data-zombie="close"' in page.text
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
page = client.get("/health/Projets/demo")
assert "Aucune carte zombie" in page.text
+92
View File
@@ -0,0 +1,92 @@
"""Palier 12 (0.15.0 → refonte 0.16.0): layout barre latérale (#16)
et wallboard sans défilement parasite."""
import app.main as main
def test_sidebar_on_app_pages(client, fake_gitea):
for path in ("/", "/focus", "/today", "/briefing", "/forecast",
"/rules", "/health", "/standup", "/capture"):
page = client.get(path)
assert page.status_code == 200, path
assert page.text.count('id="sidebar"') == 1, path
assert page.text.count('id="nav-burger"') == 1, path
assert "with-sidebar" in page.text, path
assert "/static/js/nav.js" in page.text, path
# Les quatre sections accordéon de la barre latérale.
for group in ("Principal", "Rituels", "Pilotage", "Outils"):
assert group in page.text, (path, group)
for gkey in ("principal", "rituels", "pilotage", "outils"):
assert f'data-group-btn="{gkey}"' in page.text, (path, gkey)
assert page.text.count('class="side-acc"') == 4, path
assert "kanbante-nav-closed" in page.text # bootstrap pré-rendu
assert 'href="/health"' in page.text
assert 'id="side-collapse"' in page.text
# Bouton palette : icône seule, à droite du titre — pas de
# libellé texte « Recherche & commandes » visible.
assert 'class="side-head"' in page.text
assert ">🔍</button>" in page.text
# Le libellé de l'ancien bouton palette a disparu (le lien de
# navigation « Recherche », lui, existe toujours — c'est le
# texte complet « Recherche & commandes » qui doit manquer).
assert 'side-label">Recherche &amp; commandes' not in page.text
assert "Recherche & commandes</span>" not in page.text
def test_sidebar_active_states(client, fake_gitea):
home = client.get("/")
assert 'href="/" class="side-link active"' in home.text
forecast = client.get("/forecast")
assert 'href="/forecast" class="side-link active"' in forecast.text
today = client.get("/today")
assert 'href="/today" class="side-link active"' in today.text
def test_board_topbar_crumb_and_gitea_link(client, fake_gitea):
board = client.get("/board/Projets/demo")
assert board.status_code == 200
assert 'href="/board/Projets/demo"' in board.text # fil d'Ariane topbar
assert "Gitea ↗" in board.text
assert 'id="sidebar"' in board.text
def test_wall_has_no_sidebar(client, fake_gitea):
wall = client.get("/wall")
assert wall.status_code == 200
assert 'id="sidebar"' not in wall.text # plein écran TV assumé
assert "wall-foot" in wall.text
def test_nav_js_contract(client, fake_gitea):
js = client.get("/static/js/nav.js")
assert js.status_code == 200
assert "sidebar-collapsed" in js.text
assert "localStorage" in js.text
assert "side-backdrop" in js.text
assert "Escape" in js.text
assert "kanbante-nav-open" in js.text # accordéon exclusif persisté (0.22.0)
assert "setOpenGroup" in js.text
assert "data-group-btn" in js.text
def test_wall_more_cards_line(client, fake_gitea, monkeypatch):
async def fake_collect(only_repo):
return [{
"full_name": "Projets/demo",
"counts": {c: 0 for c in
("backlog", "todo", "in_progress", "qa", "review", "done")},
"open_total": 12,
"cards": [
{"number": i, "title": f"Carte {i}", "column": "in_progress",
"column_label": "🚧 En cours", "age_days": 2, "aging": False}
for i in range(1, 10)
],
"cards_total": 12,
"closed_today": 1,
}]
monkeypatch.setattr(main, "_collect_wall_data", fake_collect)
page = client.get("/wall")
assert page.status_code == 200
assert "+3 autres cartes en vol" in page.text
assert "📋 Ouvertes" in page.text
+87
View File
@@ -0,0 +1,87 @@
"""Palier 13 (0.17.0): vague 2 — jalon sur la carte (#17), pastille
de santé (#18), épinglés via étoiles Gitea (#20), page Projet (#25)."""
from tests.conftest import csrf_from
def test_dashboard_shows_next_milestone(client, fake_gitea):
page = client.get("/")
assert page.status_code == 200
# Faux : Sprint 1, échéance 2026-10-20, 1 fermée / 2 au total.
assert "🎯 Sprint 1" in page.text
assert "1/2 terminées" in page.text
assert "échéance" in page.text
def test_dashboard_health_dot(client, fake_gitea):
page = client.get("/")
assert 'class="health-dot ' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_no_starred_section_by_default(client, fake_gitea):
page = client.get("/")
assert 'id="pinned-section" hidden' in page.text
assert 'data-starred="0"' in page.text
def test_starred_repo_is_pinned(client, fake_gitea):
fake_gitea.starred.add("Projets/demo")
page = client.get("/")
assert 'id="pinned-section" hidden' not in page.text
assert 'data-starred="1"' in page.text
# La carte épinglée sort de la grille de son onglet d'origine :
# une seule carte (div) pour demo — le bouton étoile porte aussi
# un data-full, on compte donc le motif de la carte elle-même.
assert page.text.count('data-full="Projets/demo" data-home') == 1
def test_star_toggle_endpoint(client, fake_gitea):
csrf = csrf_from(client)
denied = client.post(
"/api/projects/Projets/demo/star", data={"starred": "1"}
)
assert denied.status_code == 403
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "1", "csrf_token": csrf},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "starred": True}
assert "Projets/demo" in fake_gitea.starred
resp = client.post(
"/api/projects/Projets/demo/star",
data={"starred": "0", "csrf_token": csrf},
)
assert resp.json() == {"ok": True, "starred": False}
assert "Projets/demo" not in fake_gitea.starred
def test_card_title_links_to_project_page(client, fake_gitea):
page = client.get("/")
assert 'href="/project/Projets/demo"' in page.text
assert 'href="/board/Projets/demo"' in page.text # accès board conservé
def test_project_page(client, fake_gitea):
from app import models
models.log_event("Projets/demo", 1, "created", to_column="backlog")
models.log_event(
"Projets/demo", 1, "moved",
from_column="backlog", to_column="in_progress",
)
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "🎯 Jalons ouverts" in page.text
assert "Sprint 1" in page.text
assert "🩺 Santé du flux" in page.text
assert "🔮 Prévisions" in page.text
assert "🕘 Activité récente" in page.text
assert "📝 Notes du projet" in page.text
assert 'href="/board/Projets/demo"' in page.text
assert "/wall?repo=Projets/demo" in page.text
# Titre du projet et lien Gitea du faux.
assert "demo" in page.text
+74
View File
@@ -0,0 +1,74 @@
"""Palier 14 (0.18.0): vague 2 suite — activité 7 jours (#19),
tri/regroupement (#21), vue liste dense (#22), actions rapides (#23)."""
from datetime import datetime, timedelta, timezone
from app import models
def _ts(days_ago: int) -> str:
dt = datetime.now(timezone.utc) - timedelta(days=days_ago)
return dt.strftime("%Y-%m-%d 12:00:00")
def test_activity_line_recent_week(client, fake_gitea):
for _ in range(2):
models.log_event("Projets/demo", 1, "closed",
to_column="done", created_at=_ts(1))
models.log_event("Projets/demo", 5, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(0))
page = client.get("/")
assert "2 fermée(s) · 1 déplacement(s) cette semaine" in page.text
def test_activity_line_idle_project(client, fake_gitea):
models.log_event("Projets/vieux", 9, "moved",
from_column="backlog", to_column="todo",
created_at=_ts(35))
page = client.get("/")
assert "aucune activité kanban depuis 35 j" in page.text
def test_dashboard_display_tools_markup(client, fake_gitea):
page = client.get("/")
assert "dashState" in page.text
assert 'id="dash-sort"' in page.text
assert 'id="dash-group"' in page.text
assert "Grouper par propriétaire" in page.text
assert "setView('grid')" in page.text
assert "setView('list')" in page.text
def test_dense_list_view_tables(client, fake_gitea):
page = client.get("/")
assert page.text.count('class="proj-table"') == 3
assert 'data-sort="open"' in page.text
assert 'data-sort="inflight"' in page.text
# La ligne dense de demo : lien page projet + compteurs.
assert 'href="/project/Projets/demo"' in page.text
def test_dashboard_js_contract(client, fake_gitea):
js = client.get("/static/js/dashboard.js")
assert js.status_code == 200
assert "kanbante-dash-sort" in js.text
assert "kanbante-dash-group" in js.text
assert "owner-group" in js.text
assert "data-menu-btn" in js.text
def test_card_quick_actions_menu(client, fake_gitea):
page = client.get("/")
assert "data-menu-btn" in page.text
# urlencode de Jinja conserve les « / » : le lien est valide tel quel.
assert 'href="/capture?repo=Projets/demo"' in page.text
assert 'href="/triage/Projets/demo"' in page.text
assert 'href="/sprint/Projets/demo"' in page.text
assert 'href="/health/Projets/demo"' in page.text
def test_capture_prefilled_repo(client, fake_gitea):
page = client.get("/capture?repo=Projets/kanbante")
assert page.status_code == 200
assert 'value="Projets/kanbante"' in page.text
+112
View File
@@ -0,0 +1,112 @@
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
- A : les boucles par dépôt sont parallélisées (le comportement
visible ne change pas — couvert par les paliers précédents) et
le cache du client passe en stale-while-revalidate (testé dans
test_gitea_client.py).
- C : /forecast devient un tableau de bord des jalons (une ligne
par jalon, ancres depuis le dashboard) et /health un tableau
trié par sévérité, avec sélecteur de projet sur le détail.
"""
import asyncio
import httpx
from app.gitea_client import GiteaClient
def test_forecast_is_a_portfolio_table(client):
r = client.get("/forecast")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "jalon(s) ouvert(s)" in r.text
# One row for the demo milestone, anchored for dashboard links.
assert 'id="ms-Projets-demo"' in r.text
assert "Sprint 1" in r.text
# The per-project pace table is still there.
assert "Débit par projet" in r.text
def test_dashboard_milestone_links_into_forecast(client):
r = client.get("/")
assert r.status_code == 200
assert 'href="/forecast#ms-Projets-demo"' in r.text
def test_health_index_is_a_severity_table(client):
r = client.get("/health")
assert r.status_code == 200
assert "pilot-table" in r.text
assert "data-severity" in r.text
assert "Projets/demo" in r.text
def test_health_detail_has_project_switcher(client):
r = client.get("/health/Projets/demo")
assert r.status_code == 200
assert 'id="health-switch"' in r.text
assert '<option value="Projets/demo" selected>' in r.text
assert "tous les projets" in r.text
async def test_swr_serves_stale_then_refreshes_in_background():
"""Stale entries are served instantly; the refresh lands
asynchronously and the next read sees the fresh value."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
first = await client.search_repos()
assert first == [{"id": 1}]
# Fresh cache: no new fetch.
assert await client.search_repos() == [{"id": 1}]
assert calls["n"] == 1
# Age the entry beyond the TTL: the stale value comes back
# immediately (same content, no wait)…
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
assert await client.search_repos() == [{"id": 1}]
# …and the background refresh replaces it.
await asyncio.sleep(0.3)
assert await client.search_repos() == [{"id": 2}]
await client.aclose()
async def test_swr_mutation_clear_blocks_stale_resurrection():
"""A refresh started before a cache clear (mutation) must not
put its now-stale result back."""
calls = {"n": 0}
def handler(request):
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
calls["n"] += 1
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
client = GiteaClient(
base_url="http://gitea.test",
token="",
cache_ttl=60,
transport=httpx.MockTransport(handler),
)
await client.search_repos()
ts, value = client._cache["search_repos"]
client._cache["search_repos"] = (ts - 120, value)
await client.search_repos() # triggers background refresh
client._cache_clear() # a mutation lands first
await asyncio.sleep(0.3)
# The cache was cleared and the in-flight refresh was disarmed:
# the next read fetches fresh instead of serving the old value.
assert await client.search_repos() == [{"id": 3}]
await client.aclose()
+36
View File
@@ -0,0 +1,36 @@
"""Palier 16 (0.20.0) — refonte professionnelle de l'entête Projets.
Fini le « Bonjour 👋 » et les rangées éparses : titre sobre,
bande KPI unifiée, et une seule barre d'outils (onglets
segmentés, filtre, tri, regroupement, vue). Les crochets
fonctionnels (ids, liaisons Alpine) sont inchangés.
"""
def test_dashboard_professional_header(client):
r = client.get("/")
assert r.status_code == 200
# Titre sobre + sous-titre, plus de formule familière.
assert "<h1>Projets</h1>" in r.text
assert "Bonjour" not in r.text
# Bande KPI unifiée : valeurs + libellés.
assert "kpi-strip" in r.text
assert "Projets actifs" in r.text
assert "Issues fermées · 30 j" in r.text
assert "Temps de cycle moyen" in r.text
# Les KPI journal mènent à l'activité.
assert 'href="/activity"' in r.text
def test_dashboard_single_toolbar_keeps_its_hooks(client):
r = client.get("/")
assert "dash-toolbar" in r.text
# Onglets segmentés avec compteurs, mêmes liaisons Alpine.
assert "seg-tabs" in r.text
assert "seg-count" in r.text
assert "tab = 'inactive'" in r.text
# Filtre, tri, regroupement et vue : ids préservés pour
# dashboard.js et l'état Alpine.
for hook in ('id="project-search"', 'id="dash-sort"',
'id="dash-group"', "setView('list')"):
assert hook in r.text, hook
+36
View File
@@ -0,0 +1,36 @@
"""Palier 17 (0.21.0) — sélecteur de dépôt intelligent (Capture).
- GET /api/repos : liste exhaustive des dépôts pour les
sélecteurs (la palette reste plafonnée à 60), épinglés
d'abord, archivés en dernier.
- La page /capture branche le sélecteur sur le champ Dépôt
(liste filtrée + complétion Tab par segment, repo-picker.js).
"""
def test_api_repos_lists_everything_ranked(client, fake_gitea):
fake_gitea.starred.add("Projets/vieux")
r = client.get("/api/repos")
assert r.status_code == 200
repos = r.json()["repos"]
names = [x["full_name"] for x in repos]
# Exhaustif : actifs + inactifs + archivés du faux Gitea.
assert names == ["Projets/vieux", "Projets/demo", "Projets/archive"] or set(names) == {
"Projets/demo", "Projets/vieux", "Projets/archive",
}
# L'épinglé passe devant malgré son inactivité.
assert names[0] == "Projets/vieux"
by_name = {x["full_name"]: x for x in repos}
assert by_name["Projets/vieux"]["starred"] is True
assert by_name["Projets/demo"]["starred"] is False
assert by_name["Projets/archive"]["archived"] is True
def test_capture_page_wires_the_repo_picker(client):
r = client.get("/capture")
assert r.status_code == 200
assert 'id="capture-repo"' in r.text
assert 'id="repo-listbox"' in r.text
assert "repo-picker.js" in r.text
# Le contrat du formulaire ne change pas.
assert 'name="repo_full_name"' in r.text
+139
View File
@@ -0,0 +1,139 @@
"""Palier 18 (0.22.0) — login screen, sidebar, chantier B.
- Connexion par formulaire + cookie de session signé (le Basic
reste accepté pour les scripts) ; les navigateurs sont
redirigés vers /login, les API reçoivent 401.
- Bouton replier dans l'en-tête de la barre latérale (icône
seule, à droite de la recherche) ; accordéon exclusif.
- Instantanés : persistance des réponses du client, hydratation
au démarrage, étiquette de fraîcheur.
"""
import re
import httpx
from fastapi.testclient import TestClient
import app.main as main
from app import models
from app.gitea_client import GiteaClient
from app.snapshots import freshness_label
def _auth_client(monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
return TestClient(main.app)
def _csrf_from(html: str) -> str:
m = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert m, "le formulaire de connexion doit porter le jeton CSRF"
return m.group(1)
def test_browser_is_redirected_to_login(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
assert r.headers["location"].startswith("/login")
# Les API, elles, reçoivent un 401 sec (pas de redirection).
r = client.get("/api/repos")
assert r.status_code == 401
def test_login_flow_and_logout(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
page = client.get("/login")
assert page.status_code == 200
assert "login-card" in page.text
token = _csrf_from(page.text)
bad = client.post(
"/login",
data={"username": "bruno", "password": "non", "csrf_token": token},
follow_redirects=False,
)
assert bad.status_code == 401
assert "Identifiants incorrects" in bad.text
ok = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "/focus",
},
follow_redirects=False,
)
assert ok.status_code == 303
assert ok.headers["location"] == "/focus"
# La session ouvre les pages sans autre en-tête.
assert client.get("/").status_code == 200
client.get("/logout")
r = client.get("/", headers={"Accept": "text/html"}, follow_redirects=False)
assert r.status_code == 303
def test_login_next_is_local_only(monkeypatch, fake_gitea):
with _auth_client(monkeypatch, fake_gitea) as client:
token = _csrf_from(client.get("/login").text)
r = client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"csrf_token": token,
"next": "https://evil.example/phish",
},
follow_redirects=False,
)
assert r.status_code == 303
assert r.headers["location"] == "/"
def test_sidebar_head_and_exclusive_accordion(client):
r = client.get("/")
assert r.status_code == 200
head = r.text.split('class="side-nav"')[0]
assert 'id="side-collapse"' in head # replier dans l'en-tête…
assert head.index("side-search") < head.index('id="side-collapse"') # …à droite de la recherche
assert '<span class="side-label">Replier</span>' not in r.text # icône seule
assert "kanbante-nav-open" in r.text # accordéon exclusif (amorce)
async def test_snapshot_write_through_and_hydration():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if request.url.path.endswith("/user"):
return httpx.Response(200, json={"id": 7})
return httpx.Response(
200,
json={"data": [{"full_name": "Projets/demo", "archived": False}]},
headers={"X-Total-Count": "1"},
)
writer = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(handler),
)
repos = await writer.search_repos()
assert repos and repos[0]["full_name"] == "Projets/demo"
stored = models.load_snapshots()
assert "search_repos" in stored
def boom(request):
raise AssertionError("Gitea ne doit pas être appelé : l'instantané suffit")
reader = GiteaClient(
base_url="http://gitea.test", cache_ttl=30,
transport=httpx.MockTransport(boom),
)
assert reader.hydrate_from_snapshots() >= 1
served = await reader.search_repos()
assert served[0]["full_name"] == "Projets/demo"
assert freshness_label() == "à l'instant"
+204
View File
@@ -0,0 +1,204 @@
"""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
# En-tête refondu en 0.20.0 : bande KPI unifiée (les stat-tile
# ne servent plus qu'au briefing).
assert "kpi-strip" 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"
+81
View File
@@ -0,0 +1,81 @@
"""Palier 7 (0.10.0): mode Triage (issue #11)."""
import json
import re
from tests.conftest import csrf_from
def _queue_from(page_html: str) -> list:
m = re.search(
r'<script id="triage-queue" type="application/json">(.*?)</script>',
page_html, re.S,
)
assert m, "file de triage absente de la page"
return json.loads(m.group(1))
def test_triage_picker_lists_project_with_backlog(client, fake_gitea):
r = client.get("/triage")
assert r.status_code == 200
assert "Projets/demo" in r.text
assert "au backlog" in r.text
def test_triage_session_queue_is_backlog_only(client, fake_gitea):
r = client.get("/triage/Projets/demo")
assert r.status_code == 200
queue = _queue_from(r.text)
numbers = [c["number"] for c in queue]
# Open issues of the fake repo: #1 and #5, both unplaced -> backlog.
# #3 is closed (goes to done), #2 is a PR (excluded).
assert numbers == [1, 5]
card = queue[0]
assert card["title"] == "Tâche un"
assert "excerpt" in card and "html_url" in card
def test_triage_session_empty_when_nothing_to_triage(client, fake_gitea):
for issue in fake_gitea.issues["Projets/demo"]:
if issue["number"] in (1, 5):
issue["state"] = "closed"
r = client.get("/triage/Projets/demo")
assert r.status_code == 200
assert _queue_from(r.text) == []
def test_assign_self_adds_token_user(client, fake_gitea):
token = csrf_from(client)
# Issue #5 is unassigned in the fake; #1 already belongs to bruno.
r = client.post(
"/api/issues/Projets/demo/5/assign-self",
data={"csrf_token": token},
)
assert r.status_code == 200
assert r.json()["ok"] is True
assert r.json()["assignees"] == ["bruno"]
assert fake_gitea.assignee_changes == [("Projets", "demo", 5, ["bruno"])]
# Idempotent: assigning #1 (already bruno's) writes nothing.
r = client.post(
"/api/issues/Projets/demo/1/assign-self",
data={"csrf_token": token},
)
assert r.status_code == 200
assert r.json()["assignees"] == ["bruno"]
assert len(fake_gitea.assignee_changes) == 1
def test_assign_self_requires_csrf(client, fake_gitea):
r = client.post("/api/issues/Projets/demo/1/assign-self", data={})
assert r.status_code == 403
assert fake_gitea.assignee_changes == []
def test_excerpt_strips_markdown():
from app.main import _excerpt
text = _excerpt("# Titre\n\nDu **gras** et un [lien](http://x) ici.", 280)
assert "Titre" in text and "gras" in text and "lien" in text
assert "#" not in text and "**" not in text
assert _excerpt(None) == ""
assert len(_excerpt("mot " * 200, 50)) <= 51
+41
View File
@@ -0,0 +1,41 @@
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
from datetime import datetime, timezone
def _make_two_active_projects(fake_gitea):
"""The fake's second repo is 800 days old (inactive): revive it."""
now = datetime.now(timezone.utc)
for repo in fake_gitea.repos:
if repo["full_name"] == "Projets/vieux":
repo["updated_at"] = now.strftime("%Y-%m-%dT%H:%M:%SZ")
def test_wall_nav_markup_with_multiple_slides(client, fake_gitea):
_make_two_active_projects(fake_gitea)
r = client.get("/wall")
assert r.status_code == 200
assert r.text.count("wall-slide") >= 2
assert 'id="wall-prev"' in r.text
assert 'id="wall-next"' in r.text
assert 'id="wall-dots"' in r.text
assert 'id="wall-paused"' in r.text
assert "1 / 2" in r.text and "2 / 2" in r.text
def test_wall_no_nav_with_single_slide(client, fake_gitea):
r = client.get("/wall")
assert r.status_code == 200
assert 'id="wall-prev"' not in r.text
def test_wall_js_contract():
js = open("static/js/wall.js", encoding="utf-8").read()
# Arrow navigation + hard pause + position persistence.
assert "ArrowRight" in js and "ArrowLeft" in js
assert "sessionStorage" in js
assert "MANUAL_PAUSE_MS" in js
# The live listener must use the NAMED gitea event (onmessage
# never fires for named SSE events — the 0.6.0 bug).
assert "addEventListener('gitea'" in js
assert "source.onmessage" not in js
+176
View File
@@ -0,0 +1,176 @@
"""Palier 9 (0.12.0): Focus du jour (#5), Prévisions (#7), Récit (#4)."""
from datetime import date, timedelta
from app import forecast as fc
from app import models
from tests.conftest import csrf_from
def _ev(day: date, kind: str = "closed") -> dict:
return {"event_type": kind, "created_at": day.strftime("%Y-%m-%d 10:00:00")}
# ── Prévisions : fonctions pures ──────────────────────────────────
def test_weekly_closures_buckets():
today = date(2026, 10, 14) # a Wednesday
monday = today - timedelta(days=2)
events = [
_ev(monday), _ev(monday + timedelta(days=1)), # this week: 2
_ev(monday - timedelta(days=7)), # last week: 1
_ev(monday - timedelta(days=30), "moved"), # not a closure
_ev(monday - timedelta(days=90)), # too old
]
weekly = fc.weekly_closures(events, weeks=8, today=today)
assert len(weekly) == 8
assert weekly[-1]["count"] == 2
assert weekly[-2]["count"] == 1
assert sum(b["count"] for b in weekly) == 3
def test_throughput_excludes_current_week_and_needs_history():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = []
# 4 complete weeks: 2, 4, 0, 6 closures; current week: 99 (ignored)
for weeks_ago, n in ((1, 2), (2, 4), (3, 0), (4, 6)):
for k in range(n):
events.append(_ev(monday - timedelta(weeks=weeks_ago) + timedelta(days=k % 5)))
for k in range(9):
events.append(_ev(monday + timedelta(days=k % 2)))
weekly = fc.weekly_closures(events, weeks=8, today=today)
stats = fc.throughput_stats(weekly)
assert stats == {"avg": 3.0, "min": 0, "max": 6, "weeks": 4}
assert fc.throughput_stats(fc.weekly_closures([], today=today)) is None
def test_estimate_dates_and_labels():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = [_ev(monday - timedelta(weeks=w)) for w in (1, 2, 3, 4) for _ in range(2)]
weekly = fc.weekly_closures(events, weeks=8, today=today)
est = fc.estimate(6, weekly, today=today)
assert est["done"] is False
assert est["avg_per_week"] == 2.0
assert est["central"] == today + timedelta(days=21)
# min == max == 2/week -> no spread: latest==earliest==central
assert est["earliest"] == est["central"] == est["latest"]
label = fc.eta_label(est)
assert "à ce rythme ≈" in label
assert fc.estimate(0, weekly, today=today) == {"done": True}
assert fc.estimate(5, [], today=today) is None
assert fc.french_date(date(2026, 11, 3)) == "3 novembre"
def test_metrics_include_weekly_closures(client, fake_gitea):
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/metrics", params={"repo": "Projets/demo"})
assert r.status_code == 200
weekly = r.json()["metrics"]["weekly_closures"]
assert len(weekly) == 8
assert weekly[-1] == 1 # the closure we just logged is this week
def test_forecast_page_and_milestone_banner(client, fake_gitea):
r = client.get("/forecast")
assert r.status_code == 200
assert "Prévisions" in r.text
assert "Projets/demo" in r.text
assert "Sprint 1" in r.text
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
assert board.status_code == 200
assert "🔮" in board.text
assert "historique pour prévoir" in board.text # empty journal
assert "/sprint/Projets/demo" in board.text
# ── Focus du jour ─────────────────────────────────────────────────
def _pin(client, token, number, repo="Projets/demo"):
return client.post("/api/today/pin", data={
"repo_full_name": repo, "issue_number": number, "csrf_token": token,
})
def test_today_pin_unpin_flow(client, fake_gitea):
token = csrf_from(client)
r = _pin(client, token, 1)
assert r.status_code == 200 and r.json()["count"] == 1
r = _pin(client, token, 1) # idempotent
assert r.json().get("already") is True
page = client.get("/today")
assert page.status_code == 200
assert "Tâche un" in page.text
r = client.post("/api/today/unpin", data={
"repo_full_name": "Projets/demo", "issue_number": 1, "csrf_token": token,
})
assert r.status_code == 200
assert models.get_picks(models_now()) == []
def models_now() -> str:
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
def test_today_pin_cap_three(client, fake_gitea):
token = csrf_from(client)
today = models_now()
for n in (11, 12, 13):
models.add_pick(today, "Projets/demo", n)
r = _pin(client, token, 1)
assert r.status_code == 400
assert "3 cartes max" in r.json()["error"]
def test_briefing_includes_today_picks(client, fake_gitea):
models.add_pick(models_now(), "Projets/demo", 5)
r = client.get("/api/briefing")
assert r.status_code == 200
picks = r.json()["briefing"]["today_picks"]
assert [p["number"] for p in picks] == [5]
assert picks[0]["title"] == "Tâche focus"
def test_today_page_shows_candidates(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
page = client.get("/today")
assert page.status_code == 200
assert "Candidates" in page.text
assert "Tâche un" in page.text
# ── Récit de sprint ───────────────────────────────────────────────
def test_sprint_markdown_from_fake_milestone(client, fake_gitea):
models.log_event("Projets/demo", 3, "moved", to_column="in_progress")
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert r.status_code == 200
md = r.json()["markdown"]
assert "# 📖 Récit de sprint — Projets/demo · Sprint 1" in md
assert "## ✅ Livré (1)" in md
assert "Ancienne tâche" in md
assert "## ↩️ Reporté (1)" in md
assert "Tâche un" in md
page = client.get("/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert page.status_code == 200
assert "Copier le markdown" in page.text
def test_sprint_unknown_milestone_404(client, fake_gitea):
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Nope"})
assert r.status_code == 404
page = client.get("/sprint/Projets/demo")
assert page.status_code == 200
assert "Sprint 1" in page.text # picker lists the milestones
+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