Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03fa1eb44e | ||
|
|
8e0c7b8f09 | ||
|
|
4c5d1dee23 | ||
|
|
cdabc77ccd | ||
|
|
fbe58bbf16 | ||
|
|
6b9ec1ff99 | ||
|
|
1d3794ba55 | ||
|
|
715687da66 | ||
|
|
2bc8ccaf4f | ||
|
|
17a131d71b | ||
|
|
8a32d080b5 | ||
|
|
25040f974c | ||
|
|
99764e71b2 | ||
|
|
1088dd0b48 | ||
|
|
1743c8c101 | ||
|
|
411c0d2934 | ||
|
|
6e71b87d09 | ||
|
|
0417d0593f | ||
|
|
39b751df56 | ||
|
|
1cc27bd4fb | ||
|
|
0c280b3c7e | ||
|
|
395ab6e14c | ||
|
|
05b8c506e0 | ||
|
|
943ecd6a6b | ||
|
|
af59417d07 | ||
|
|
a358c45d73 |
+170
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+37
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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 || '')) {
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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")
|
||||
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user