26 Commits
Author SHA1 Message Date
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
44 changed files with 4627 additions and 48 deletions
+170
View File
@@ -1,5 +1,175 @@
# Changelog
## 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
+32 -3
View File
@@ -5,7 +5,7 @@
## 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
@@ -14,7 +14,17 @@
- 🗂️ **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
- 📥 **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
@@ -110,6 +120,12 @@ sudo systemctl enable --now docker
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
## Sécurité
@@ -151,7 +167,20 @@ 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 |
| 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 |
+16
View File
@@ -27,6 +27,22 @@ def format_briefing_text(data: dict) -> str:
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']}")
+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)
+56 -1
View File
@@ -36,6 +36,38 @@ Environment variables:
the daily briefing is sent to ntfy.
Empty (default) = no automatic send,
the /briefing page stays available.
KANBANTE_CAPTURE_REPO Default repo for quick capture
(POST /api/capture, the /capture page
and the ntfy capture listener), e.g.
"Projets/kanbante". Empty = captures
must name their repo explicitly.
KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture
(header X-Capture-Token or Bearer).
Empty (default) = the endpoint is
disabled (503). Distinct from the
Gitea token; revocable on its own.
KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures:
each message published there becomes
an issue in KANBANTE_CAPTURE_REPO.
Uses KANBANTE_NTFY_URL/_TOKEN as the
server. Empty (default) = no listener.
KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is
composed (and ntfy-notified) on
KANBANTE_DIGEST_WEEKDAY. The digest is
NEVER auto-delivered: it waits for a
one-click approval on /digest.
KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0).
KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest
as JSON {title, markdown, week}.
KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the
digest is delivered by calling a
note-creation tool on it (client MCP).
KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP.
KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call
(see /digest for the live tool list).
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
"{title}" and "{content}" placeholders
are substituted (JSON-escaped).
"""
import os
@@ -44,7 +76,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.5.2"
APP_VERSION = "0.12.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -100,6 +132,16 @@ class Settings:
ntfy_topic: str = ""
ntfy_token: str = ""
briefing_time: str = ""
capture_repo: str = ""
capture_token: str = ""
capture_ntfy_topic: str = ""
digest_time: str = ""
digest_weekday: int = 0
digest_webhook_url: str = ""
obsigate_mcp_url: str = ""
obsigate_mcp_token: str = ""
obsigate_mcp_tool: str = ""
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
@property
def auth_enabled(self) -> bool:
@@ -129,4 +171,17 @@ def get_settings() -> Settings:
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""),
digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0),
digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""),
obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""),
obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""),
obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""),
obsigate_mcp_args=os.getenv(
"KANBANTE_OBSIGATE_MCP_ARGS",
'{"title": "{title}", "content": "{content}"}',
),
)
+188
View File
@@ -0,0 +1,188 @@
"""Weekly digest (roadmap #13): the week in Kanbante, as a note.
Composes a markdown digest from the event journal (last 7 days) and
the current focus snapshot, then — ONLY after a human approves on
/digest (standing rule: cross-app writes need approval) — delivers
it to ObsiGate through its MCP server (Kanbante as MCP *client*:
initialize → tools/call) or to a generic JSON webhook.
The ObsiGate tool contract is configurable (URL, tool name, JSON
argument template with {title}/{content} placeholders) because the
exact tool names of the target server are an operator decision, not
something to hardcode blindly.
"""
import json
import logging
import httpx
logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05"
def format_digest_markdown(
week_label: str,
closed_items: list[dict],
created_count: int,
moved_count: int,
focus_groups: list[dict],
) -> str:
"""Render the weekly digest as markdown (Obsidian-friendly)."""
lines = [f"# 🧠 Kanbante — semaine du {week_label}", ""]
lines.append(f"## ✅ Livré ({len(closed_items)})")
if closed_items:
for item in closed_items:
title = item.get("title") or ""
lines.append(
f"- [{item['repo']}#{item['number']}]({item.get('html_url', '')})"
f" {title}".rstrip()
)
else:
lines.append("- Rien n'a été fermé cette semaine.")
lines.append("")
lines.append("## 🚧 En cours")
total_focus = sum(len(g["cards"]) for g in focus_groups)
if total_focus:
for group in focus_groups:
lines.append(f"### {group['full_name']}")
for card in group["cards"]:
issue = card["issue"]
lines.append(
f"- #{issue['number']} {issue.get('title', '')}"
f" — *{card['column']}*"
)
else:
lines.append("- Aucune carte en cours, en QA ou en révision.")
lines.append("")
lines.append("## 📈 Activité de la semaine")
lines.append(f"- Issues créées : {created_count}")
lines.append(f"- Déplacements de cartes : {moved_count}")
lines.append(f"- Issues fermées : {len(closed_items)}")
lines.append("")
lines.append("*Digest composé par Kanbante à partir de son journal d'événements.*")
return "\n".join(lines)
def render_mcp_args(template: str, title: str, content: str) -> dict:
"""Substitute {title}/{content} in a JSON template, safely."""
def esc(value: str) -> str:
return json.dumps(value, ensure_ascii=False)[1:-1]
rendered = template.replace("{title}", esc(title)).replace(
"{content}", esc(content)
)
try:
args = json.loads(rendered)
except ValueError as exc:
raise ValueError(f"KANBANTE_OBSIGATE_MCP_ARGS is not valid JSON: {exc}")
if not isinstance(args, dict):
raise ValueError("KANBANTE_OBSIGATE_MCP_ARGS must be a JSON object")
return args
async def send_webhook(url: str, title: str, markdown: str, week: str) -> tuple[bool, str]:
"""POST the digest as JSON to a generic webhook."""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
url, json={"title": title, "markdown": markdown, "week": week}
)
if response.status_code < 300:
return True, f"webhook HTTP {response.status_code}"
return False, f"webhook HTTP {response.status_code}: {response.text[:200]}"
except Exception as exc:
return False, f"webhook error: {exc}"
def _mcp_headers(token: str) -> dict:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _parse_mcp_body(response: httpx.Response) -> dict:
"""MCP streamable HTTP answers JSON or an SSE frame — accept both."""
text = response.text.strip()
if not text:
return {}
if text.startswith("{"):
return json.loads(text)
payload: dict = {}
for line in text.splitlines():
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk.startswith("{"):
payload = json.loads(chunk)
return payload
async def _mcp_rpc(client: httpx.AsyncClient, url: str, token: str,
method: str, params: dict, req_id: int) -> dict:
response = await client.post(
url,
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
headers=_mcp_headers(token),
)
response.raise_for_status()
payload = _parse_mcp_body(response)
if "error" in payload:
err = payload["error"] or {}
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
return payload.get("result") or {}
async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
await _mcp_rpc(
client, url, token, "initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.12.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}"
+119
View File
@@ -0,0 +1,119 @@
"""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
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 date.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 date.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
+12
View File
@@ -340,3 +340,15 @@ class GiteaClient:
r.raise_for_status()
self._cache_clear()
return r.json()
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()
+1156 -6
View File
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -16,7 +16,7 @@ import sqlite3
from contextlib import contextmanager
from typing import Iterator
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "qa", "review", "done"]
def db_path() -> str:
@@ -79,6 +79,13 @@ 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
@@ -239,6 +246,35 @@ 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(
+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)
+34
View File
@@ -0,0 +1,34 @@
<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 %}
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
<div class="palette-overlay" id="palette-overlay" hidden>
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
<div class="palette-input-row">
<span class="palette-icon">⌨️</span>
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
placeholder="Chercher un projet, une issue, une action…">
<kbd>Échap</kbd>
</div>
<ul class="palette-results" id="palette-results"></ul>
<div class="palette-hint">↑↓ naviguer · ↵ ouvrir · Échap fermer · Ctrl+K pour rouvrir</div>
</div>
</div>
+8
View File
@@ -16,10 +16,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -57,5 +63,7 @@
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>
+17 -35
View File
@@ -20,9 +20,15 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
@@ -47,6 +53,10 @@
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
{% 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>
@@ -63,6 +73,7 @@
{% 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>
@@ -72,7 +83,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] }}">
@@ -86,39 +97,7 @@
<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>
@@ -202,6 +181,9 @@
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
<script src="/static/js/live.js" defer></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>
+8
View File
@@ -17,9 +17,15 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -67,5 +73,7 @@
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>
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Capture éclair — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>⚡ Capture éclair</h1>
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
</section>
{% if created %}
<div class="briefing-card">
<h2>✅ Capturé !</h2>
<p>Issue <strong>#{{ created.number }}</strong> créée dans
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
· <a href="/board/{{ created.repo_full_name }}">ouvrir le board</a></p>
</div>
{% endif %}
{% if error %}
<div class="briefing-card">
<h2>❌ Capture impossible</h2>
<p>{{ error }}</p>
</div>
{% endif %}
<div class="briefing-card">
<h2>✍️ Saisie rapide</h2>
<form method="post" action="/capture" class="capture-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Dépôt
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
</label>
<label>Titre de l'issue
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
</label>
<label>Détails (optionnel)
<textarea name="body" rows="4" placeholder="Contexte, lien, capture d'écran à coller dans Gitea ensuite…"></textarea>
</label>
<label>Colonne
<select name="column">
<option value="backlog" selected>📥 Backlog</option>
<option value="todo">📋 À faire</option>
</select>
</label>
<button type="submit" class="btn-save">⚡ Capturer</button>
</form>
</div>
<div class="briefing-card">
<h2>🔌 Depuis un script ou un raccourci</h2>
{% if api_enabled %}
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
-H "X-Capture-Token: VOTRE_JETON" \
-H "Content-Type: application/json" \
-d '{"text": "Idée : trier les cartes par échéance\n\nDétails ici…"}'</pre>
<p>Texte libre accepté aussi (<code>Content-Type: text/plain</code>) : la première ligne devient le titre. Le dépôt vient de <code>KANBANTE_CAPTURE_REPO</code> ou du champ <code>repo</code>.</p>
{% else %}
<p>L'endpoint <code>POST /api/capture</code> est <strong>inactif</strong> : définis <code>KANBANTE_CAPTURE_TOKEN</code> (un jeton dédié, distinct de celui de Gitea) puis redémarre Kanbante.</p>
{% endif %}
</div>
<div class="briefing-card">
<h2>📱 Depuis le téléphone (ntfy)</h2>
{% if ntfy_topic %}
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
<p>Les messages portant le tag <code>kanbante</code> sont ignorés (jamais d'auto-ingestion).</p>
{% else %}
<p>Inactif : définis <code>KANBANTE_CAPTURE_NTFY_TOPIC</code> (et <code>KANBANTE_CAPTURE_REPO</code>) puis redémarre Kanbante. Chaque message publié sur ce topic deviendra une issue — capture depuis l'application ntfy en deux touchers.</p>
{% endif %}
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+12 -1
View File
@@ -16,10 +16,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -82,9 +88,12 @@
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
{% 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] }}">
@@ -132,5 +141,7 @@
}
});
</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Digest hebdo — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>🧠 Digest hebdo → ObsiGate</h1>
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
</section>
{% if banner %}
<div class="briefing-card"><p>{{ banner }}</p></div>
{% endif %}
{% if tools is not none %}
<div class="briefing-card">
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
{% if tools %}
<ul>
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
</ul>
<p>Choisis l'outil de création de note, puis définis <code>KANBANTE_OBSIGATE_MCP_TOOL</code> (et au besoin <code>KANBANTE_OBSIGATE_MCP_ARGS</code>, gabarit JSON avec <code>{title}</code> et <code>{content}</code>).</p>
{% else %}
<p>Le serveur répond, mais n'expose aucun outil.</p>
{% endif %}
</div>
{% endif %}
<div class="briefing-card">
<h2>📤 Livraison</h2>
<p>
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
</p>
<form method="post" action="/digest/probe" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="obsigate">
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="webhook">
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
</form>
</div>
<div class="briefing-card">
<h2>👀 Aperçu du digest de la semaine</h2>
{% if pending %}
<pre class="capture-pre">{{ pending }}</pre>
{% else %}
<p>Aucun digest en attente. L'aperçu exact est composé au moment de l'envoi (boutons ci-dessus) à partir du journal des 7 derniers jours et de la vue Focus.</p>
{% endif %}
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+7
View File
@@ -20,6 +20,11 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -68,5 +73,7 @@
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>
+76
View File
@@ -0,0 +1,76 @@
<!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>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>🔮 Prévisions — « on finit quand ? »</h1>
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
</section>
{% for project in projects %}
<section class="briefing-card forecast-card">
<h2>📂 {{ project.full_name }}</h2>
<div class="forecast-flex">
<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> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
{% else %}
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
{% endif %}
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
</div>
</div>
{% if project.milestones %}
<ul class="forecast-milestones">
{% for ms in project.milestones %}
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
</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>
</body>
</html>
+8
View File
@@ -16,9 +16,15 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -59,5 +65,7 @@
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>
+8
View File
@@ -16,9 +16,15 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -65,5 +71,7 @@
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>
+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>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>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
{% if 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>
+103
View File
@@ -0,0 +1,103 @@
<!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>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill active">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="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>
+54
View File
@@ -0,0 +1,54 @@
<!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>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>📥 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>
+81
View File
@@ -0,0 +1,81 @@
<!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>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
<a href="/triage" class="nav-pill">📥 Autre projet</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<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>
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanbante — Wallboard</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="wall-body">
{% if slides %}
<div class="wall-stage" id="wall-stage">
{% for slide in slides %}
<section class="wall-slide{% if loop.first %} active{% endif %}">
<header class="wall-head">
<div>
<div class="wall-kicker">📂 Projet</div>
<h1 class="wall-project">{{ slide.full_name }}</h1>
</div>
<div class="wall-clock">
<div class="wall-time" data-clock>--:--</div>
<div class="wall-date" data-date></div>
{% if not single and slides|length > 1 %}
<div class="wall-rotation">{{ loop.index }} / {{ slides|length }}</div>
{% endif %}
</div>
</header>
<div class="wall-counts">
{% for col in columns %}
<div class="wall-count" style="--col: {{ column_colors[col] }}">
<span class="wall-count-n">{{ slide.counts[col] }}</span>
<span class="wall-count-l">{{ column_labels[col] }}</span>
</div>
{% endfor %}
</div>
{% if slide.cards %}
<ul class="wall-cards">
{% for card in slide.cards %}
<li class="wall-card">
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
{% if card.age_days is not none and card.age_days >= 1 %}
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
{% endif %}
<footer class="wall-foot">
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
<span class="wall-live">● EN DIRECT</span>
</footer>
</section>
{% endfor %}
</div>
{% 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.5.2"
version = "0.12.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+251
View File
@@ -893,3 +893,254 @@ footer {
scroll-behavior: auto !important;
}
}
/* ── Wallboard (0.6.0) : affichage plein écran, lisible à 3 mètres ── */
.wall-body {
background: #0b1220;
color: #e5ecf5;
margin: 0;
font-family: inherit;
}
.wall-stage { position: relative; min-height: 100vh; }
.wall-slide {
display: none;
min-height: 100vh;
box-sizing: border-box;
padding: 4vh 4vw;
flex-direction: column;
gap: 3vh;
}
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
.wall-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
}
.wall-kicker { font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase; color: #8fa3bf; }
.wall-project { font-size: clamp(2.2rem, 5.2vw, 4.2rem); margin: 0.4rem 0 0; line-height: 1.05; }
.wall-clock { text-align: right; }
.wall-time { font-size: clamp(2rem, 4.5vw, 3.6rem); font-weight: 800; line-height: 1; }
.wall-date { color: #8fa3bf; font-size: 1.15rem; margin-top: 0.35rem; text-transform: capitalize; }
.wall-rotation { color: #5b6b84; margin-top: 0.4rem; font-size: 1rem; }
.wall-counts {
display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px;
}
.wall-count {
border: 1.5px solid var(--col);
border-top: 6px solid var(--col);
border-radius: 14px;
padding: 14px 10px 12px;
text-align: center;
background: rgba(255, 255, 255, 0.03);
}
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wall-card {
display: flex; align-items: center; gap: 18px;
background: rgba(255, 255, 255, 0.045);
border-radius: 14px;
padding: 16px 20px;
font-size: clamp(1.15rem, 2.1vw, 1.7rem);
}
.wall-card-col {
border: 1.5px solid var(--col); color: var(--col);
border-radius: 999px; padding: 4px 14px;
font-size: 0.62em; font-weight: 700; white-space: nowrap;
}
.wall-card-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wall-card-age { color: #fbbf24; font-weight: 700; white-space: nowrap; }
.wall-card-age.hot { color: #f87171; }
.wall-idle { font-size: 1.5rem; color: #8fa3bf; }
.wall-foot {
margin-top: auto;
display: flex; gap: 36px; flex-wrap: wrap;
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
}
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
.wall-empty {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px; text-align: center;
color: #aebdd2; padding: 24px;
}
.wall-empty h1 { color: #e5ecf5; font-size: 2.6rem; margin: 0; }
.wall-empty code { background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 6px; }
@media (max-width: 900px) {
.wall-counts { grid-template-columns: repeat(3, 1fr); }
}
/* ── Capture éclair (0.7.0) ── */
.capture-form { display: flex; flex-direction: column; gap: 12px; }
.capture-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; }
.capture-form input, .capture-form textarea, .capture-form select {
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-2); color: var(--text-1); font: inherit; font-weight: 400;
}
.capture-form .btn { align-self: flex-start; }
.capture-pre {
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
}
/* ── Palette de commandes (0.8.0, #10) ── */
.palette-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(5, 10, 20, 0.62);
backdrop-filter: blur(3px);
display: flex; justify-content: center; align-items: flex-start;
padding: 10vh 16px 16px;
}
.palette-overlay[hidden] { display: none; }
.palette {
width: min(640px, 100%);
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
overflow: hidden;
animation: wall-in 0.15s ease;
}
.palette-input-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--border, #22304a);
}
.palette-input-row input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text-1); font: inherit; font-size: 1.05rem;
}
.palette-input-row kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border, #22304a);
border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; color: var(--text-3);
}
.palette-results { list-style: none; margin: 0; padding: 8px; max-height: 46vh; overflow-y: auto; }
.palette-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 10px; cursor: pointer;
}
.palette-item.active { background: var(--surface-3, #1d2942); }
.palette-item-icon { font-size: 1.05rem; width: 24px; text-align: center; }
.palette-item-main { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-item-sub { color: var(--text-3); font-size: 0.8rem; white-space: nowrap; }
.palette-empty { padding: 18px 14px; color: var(--text-3); text-align: center; }
.palette-hint {
padding: 9px 16px; border-top: 1px solid var(--border, #22304a);
color: var(--text-3); font-size: 0.78rem; text-align: right;
}
.palette-trigger { cursor: pointer; font: inherit; }
/* ── Mode Triage (0.10.0, #11) ── */
.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; }
.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; }
.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; }
.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; }
.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; }
.triage-card {
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px; padding: 26px 28px; min-height: 220px;
}
.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; }
.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); }
.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; }
.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; }
.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; }
.triage-card-assign { color: var(--text-3); }
.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
.triage-keys button {
background: var(--surface-2, #182236); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 10px;
padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer;
}
.triage-keys button:hover { border-color: var(--accent, #58a6ff); }
.triage-keys kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52);
border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px;
}
.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); }
.triage-labels {
margin-top: 14px; background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px;
}
.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; }
.triage-label-chip {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 99px;
padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer;
}
.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); }
.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; }
.triage-summary { line-height: 1.9; }
.triage-projects { display: grid; gap: 10px; }
.triage-project {
display: flex; justify-content: space-between; align-items: center;
background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a);
border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none;
}
.triage-project:hover { border-color: var(--accent, #58a6ff); }
.triage-project-count { color: var(--text-3); }
/* ── Wallboard : navigation manuelle (0.11.0) ── */
.wall-nav {
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%);
display: flex; align-items: center; gap: 14px; z-index: 50;
}
.wall-nav-btn {
width: 46px; height: 46px; border-radius: 50%;
background: rgba(19, 26, 41, 0.72); color: #dbe4f0;
border: 1px solid rgba(143, 163, 191, 0.45);
font-size: 1.15rem; cursor: pointer; backdrop-filter: blur(4px);
}
.wall-nav-btn:hover { border-color: #58a6ff; color: #fff; }
.wall-dots { display: flex; gap: 8px; }
.wall-dot {
width: 13px; height: 13px; border-radius: 50%; padding: 0;
background: rgba(143, 163, 191, 0.35); border: 1px solid rgba(143, 163, 191, 0.5);
cursor: pointer;
}
.wall-dot.on { background: #58a6ff; border-color: #58a6ff; }
.wall-paused {
position: fixed; top: 18px; left: 50%; transform: translateX(-50%);
background: rgba(19, 26, 41, 0.85); color: #f0b429;
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
padding: 6px 16px; font-size: 0.95rem; z-index: 51;
}
/* ── Focus du jour (0.12.0, #5) ── */
.pin-btn {
background: transparent; border: none; cursor: pointer;
font-size: 0.82rem; padding: 0 2px; margin-left: auto; opacity: 0.45;
}
.pin-btn:hover { opacity: 1; transform: scale(1.15); }
.today-main { max-width: 880px; margin: 0 auto; padding: 28px 16px 44px; }
.today-title { font-size: 1.9rem; margin: 0 0 4px; text-transform: capitalize; }
.today-sub { color: var(--text-3); margin: 0 0 22px; }
.today-picks { display: grid; gap: 14px; margin-bottom: 20px; }
.today-card {
background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52);
border-left: 5px solid var(--accent, #58a6ff); border-radius: 14px; padding: 20px 24px;
}
.today-card.done { border-left-color: var(--good, #3fb950); opacity: 0.85; }
.today-card-top { display: flex; justify-content: space-between; color: var(--text-3); font-size: 0.85rem; }
.today-card h2 { margin: 8px 0 12px; font-size: 1.3rem; line-height: 1.35; }
.today-card-actions { display: flex; gap: 16px; align-items: center; }
.today-card-actions a { color: var(--accent, #58a6ff); text-decoration: none; font-size: 0.9rem; }
.today-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.today-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.today-list em { color: var(--text-3); font-style: normal; font-size: 0.85rem; }
/* ── Prévisions + Récit (0.12.0, #7 #4) ── */
.forecast-flex { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
.sparkline { width: 168px; height: 48px; }
.sparkline rect { fill: var(--accent, #58a6ff); opacity: 0.85; }
.forecast-nums p { margin: 4px 0; }
.forecast-milestones { margin: 12px 0 0; padding-left: 18px; line-height: 1.8; }
.forecast-line { color: var(--accent, #58a6ff); }
.milestone-forecast { color: var(--accent, #58a6ff); font-weight: 600; }
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #22304a); border-radius: 8px; padding: 2px 10px; }
.milestone-sprint:hover { border-color: var(--accent, #58a6ff); }
.sprint-render { line-height: 1.65; }
.sprint-render h1 { font-size: 1.5rem; }
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
.sprint-render a { color: var(--accent, #58a6ff); }
+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();
});
})();
+6
View File
@@ -152,6 +152,12 @@ 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 || '')) {
+181
View File
@@ -0,0 +1,181 @@
/**
* 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: '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(); });
});
})();
+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);
})();
+9
View File
@@ -85,6 +85,7 @@ class FakeGitea:
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): [
@@ -239,6 +240,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")
+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")]
+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