Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fdf39959c | ||
|
|
18e1dd0f3d | ||
|
|
58204e621e | ||
|
|
2fde103e93 | ||
|
|
24a1ebb9a6 | ||
|
|
4c807a4344 | ||
|
|
6453b53df4 | ||
|
|
1932d11457 | ||
|
|
5b41c5ea74 | ||
|
|
370dafd266 | ||
|
|
7eef8bb604 | ||
|
|
0b48026752 | ||
|
|
675ce09083 | ||
|
|
03fa1eb44e | ||
|
|
8e0c7b8f09 | ||
|
|
4c5d1dee23 | ||
|
|
cdabc77ccd | ||
|
|
fbe58bbf16 | ||
|
|
6b9ec1ff99 | ||
|
|
1d3794ba55 | ||
|
|
715687da66 | ||
|
|
2bc8ccaf4f | ||
|
|
17a131d71b | ||
|
|
8a32d080b5 |
+150
@@ -1,5 +1,155 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Header refait** (issue #16, créée et spécifiée par Bruno) :
|
||||
les 19 copies de markup de header sont remplacées par un
|
||||
**partiel unique `_header.html`**. Desktop : une seule ligne —
|
||||
5 entrées primaires (Projets, Focus, Aujourd'hui, Recherche,
|
||||
Capture), menu « Plus ▾ » pour les 9 autres, Ctrl+K à droite,
|
||||
page active marquée (y compris dans « Plus »). Mobile
|
||||
(≤ 900 px) : burger + panneau plein largeur, cibles ≥ 44 px,
|
||||
fermeture Échap / clic extérieur. La page active est déduite du
|
||||
gabarit dans `render()` (`active_nav`) — une seule source de
|
||||
vérité. Le wallboard garde son plein écran sans header et la
|
||||
session triage utilise le même partiel. `static/js/nav.js`
|
||||
partagé, aucune dépendance ajoutée.
|
||||
|
||||
### Corrigé
|
||||
- Prévisions : les semaines se calent désormais sur le référentiel
|
||||
du journal (UTC, comme les horodatages `board_events`) — une
|
||||
fermeture journalisée en soirée locale (déjà le lendemain en
|
||||
UTC) tombait hors de toutes les semaines et faussait le débit.
|
||||
|
||||
5 tests ajoutés (170 au total).
|
||||
|
||||
## 0.14.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Créer la branche depuis la carte** (issue #3) : le panneau
|
||||
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
|
||||
max, nom libre accepté après validation git-ref) et la crée
|
||||
dans Gitea depuis la branche par défaut
|
||||
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
|
||||
« En cours » (journalisé, source `branch`, étiquette synchronisée)
|
||||
et la branche est aussitôt reconnue par le code lié (#1) et par
|
||||
l'automatisation de fusion (#2). Réponse idempotente si la
|
||||
branche existe déjà.
|
||||
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
|
||||
séjours par colonne depuis le journal (entrée → déplacement /
|
||||
fermeture) : temps médian et moyen par colonne, colonne la plus
|
||||
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
|
||||
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
|
||||
(défaut 30). Pages `/health` (résumé global) et
|
||||
`/health/{owner}/{repo}` (détail + actions rapides sur les
|
||||
zombies : commenter « toujours pertinent ? », remettre au
|
||||
Backlog, fermer — via les API existantes).
|
||||
|
||||
8 tests ajoutés (165 au total). **La feuille de route
|
||||
« Différenciation » est complète : 15/15.**
|
||||
|
||||
## 0.13.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Cartes liées au code** (issue #1) : `app/code_links.py` apparie
|
||||
branches (`feature/12-x`, `issue-12`), PRs (références `#n` dans
|
||||
titre/corps ou branche) et commits récents (références `#n`).
|
||||
Badge `⑂` sur les cartes (board + fragments temps réel), section
|
||||
« 🧬 Code lié » dans le panneau détail, endpoint
|
||||
`GET /api/issues/{o}/{r}/{n}/code`.
|
||||
- **PR fusionnée → carte Terminé** (issue #2) : le webhook accepte
|
||||
`pull_request` fermée + fusionnée ; les issues liées sont
|
||||
fermées dans Gitea si besoin et leurs cartes passent en Terminé,
|
||||
idempotent, journalisé source `webhook`.
|
||||
- **Standup automatique** (issue #6) : page `/standup` +
|
||||
`/api/standup` — fermé/déplacé **hier** (journal, titres
|
||||
résolus), focus **aujourd'hui**, **blocages** (cartes qui
|
||||
vieillissent en QA/Révision) ; bouton copier ; inclusion
|
||||
optionnelle au briefing ntfy (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`).
|
||||
- **Règles d'automatisation** (issue #8) : moteur déclaratif
|
||||
`KANBANTE_RULES` (JSON, validé : conditions `column`,
|
||||
`min_age_days`, `label`, `wip_over` ; actions `add_label`,
|
||||
`notify`, `move_top`), évalué toutes les 15 min par le
|
||||
planificateur et à la demande ; chaque action est journalisée
|
||||
(événement `rule`, source `rule`) ; page `/rules` avec état et
|
||||
dernière évaluation. Garde-fou : aucune action de fermeture
|
||||
ou de déplacement entre colonnes.
|
||||
|
||||
10 tests ajoutés (157 au total).
|
||||
|
||||
## 0.12.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Focus du jour** (issue #5) : le rituel des 3 cartes. Table
|
||||
`today_picks` ; bouton 📌 sur chaque carte du board (et sur les
|
||||
candidates) ; page `/today` calme — picks du jour avec leur
|
||||
colonne en direct, candidates de l'encours, **reprises d'hier
|
||||
non terminées** reproposées, récapitulatif « ça a bougé
|
||||
aujourd'hui » tiré du journal. Le briefing du matin inclut les
|
||||
sections « Focus du jour » et « À reprendre d'hier ».
|
||||
`POST /api/today/pin` (max 3, idempotent) et `/unpin`.
|
||||
- **Prévisions** (issue #7) : `app/forecast.py` — fermetures par
|
||||
semaine depuis le journal (8 semaines), débit moy/min/max sur
|
||||
les 4 dernières semaines complètes, projection en **fourchette**
|
||||
(jamais un faux chiffre précis) et silence explicite sans
|
||||
historique. Affichées sur le dashboard (épuisement du backlog),
|
||||
dans le bandeau milestone (avec alerte si l'ETA dépasse
|
||||
l'échéance) et sur `/forecast` (sparkline SVG inline, verdict
|
||||
par milestone). `/api/metrics` expose `weekly_closures` et
|
||||
`throughput_4w`.
|
||||
- **Récit de sprint** (issue #4) : `app/sprint.py` + page
|
||||
`/sprint/{owner}/{repo}?milestone=` — le bilan markdown du
|
||||
milestone écrit par le journal : livré (avec temps de cycle par
|
||||
carte), cartes les plus lentes, reporté avec colonnes, chiffres
|
||||
de la période ; boutons **Copier** et **Télécharger .md**,
|
||||
endpoint `GET /api/sprint/...` en JSON, lien « 📖 Récit de
|
||||
sprint » dans le bandeau milestone du board.
|
||||
|
||||
11 tests ajoutés (147 au total).
|
||||
|
||||
## 0.11.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Wallboard navigable** : flèches `←` / `→` du clavier, boutons
|
||||
❮ ❯ et pastilles cliquables pour changer de projet à la demande ;
|
||||
une navigation manuelle met la rotation en pause 90 s, `Espace`
|
||||
bascule une pause franche (badge visible). La **position est
|
||||
mémorisée** (sessionStorage) entre les rechargements : le mur ne
|
||||
repart plus à la première diapo à chaque refresh — avec 12
|
||||
projets actifs × 30 s, le cycle complet (6 min) dépassait le
|
||||
rechargement de sécurité (5 min), rendant les dernières diapos
|
||||
inatteignables et donnant l'impression d'un mur figé.
|
||||
|
||||
### Correction
|
||||
- L'écoute temps réel du wallboard utilisait `onmessage`, qui ne
|
||||
se déclenche jamais pour les événements SSE **nommés** (`gitea`)
|
||||
émis par le hub : les rechargements en direct ne partaient pas,
|
||||
seul le filet de 5 minutes fonctionnait. Corrigé en
|
||||
`addEventListener('gitea', …)`.
|
||||
|
||||
3 tests ajoutés (136 au total).
|
||||
|
||||
## 0.10.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Mode Triage** (issue #11) : vider un backlog au clavier, façon
|
||||
« inbox zero ». `/triage` liste les projets actifs et la taille
|
||||
de leur backlog ; `/triage/{owner}/{repo}` présente les cartes
|
||||
du Backlog **une par une, plein cadre** (titre, extrait du corps,
|
||||
étiquettes, âge), file embarquée côté client — aucun rechargement
|
||||
entre cartes. Décisions : `1`–`5` envoient dans À faire / En
|
||||
cours / QA / Révision / Terminé, `X` ferme, `L` ouvre le panneau
|
||||
d'étiquettes (bascule via l'API existante), `A` s'assigne l'issue
|
||||
(nouvel endpoint `POST /api/issues/{o}/{r}/{n}/assign-self`,
|
||||
idempotent), `Entrée` passe à la suivante, `Z` annule le dernier
|
||||
déplacement. Barre de progression « Carte n / N » et résumé final
|
||||
par décision. Les raccourcis sont affichés en permanence et
|
||||
cliquables. Points d'entrée : bouton « Triage du backlog » sur le
|
||||
board et action dans la palette Ctrl+K.
|
||||
|
||||
6 tests ajoutés (133 au total).
|
||||
|
||||
## 0.9.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- 🌅 **Focus du jour** — `/today` : jusqu'à 3 cartes épinglées (bouton 📌 sur les cartes), candidates de l'encours, reprises d'hier non terminées, récapitulatif du soir ; intégrées au briefing
|
||||
- 🔮 **Prévisions** — débit réel du journal (fermetures/semaine), date d'épuisement du backlog et ETA des milestones en fourchette honnête, sur le dashboard, le bandeau milestone et `/forecast` (sparkline SVG)
|
||||
- 📖 **Récit de sprint** — le bilan markdown d'un milestone (livré, cycles, plus lentes, reporté) généré depuis le journal, page `/sprint/...` + copie/téléchargement en un clic
|
||||
- 🧬 **Code lié** — branches, PRs et commits référant `#n` sur la carte (badge `⑂`) et dans le panneau détail (section « Code lié »)
|
||||
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
|
||||
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
|
||||
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
|
||||
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
|
||||
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
|
||||
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
|
||||
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
|
||||
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
|
||||
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
|
||||
@@ -119,6 +129,9 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
|
||||
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
|
||||
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
|
||||
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
|
||||
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
|
||||
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
|
||||
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
|
||||
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
|
||||
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
|
||||
@@ -168,6 +181,17 @@ kanbante/
|
||||
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
|
||||
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
|
||||
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
|
||||
| GET | `/triage` · `/triage/{owner}/{repo}` | Mode Triage : sélecteur de projet puis session clavier sur le backlog |
|
||||
| GET | `/today` | Focus du jour : les 3 cartes épinglées, candidates, reprises d'hier |
|
||||
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
|
||||
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
|
||||
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
|
||||
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
|
||||
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
|
||||
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
|
||||
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/assign-self` | S'assigner une issue (utilisateur du jeton) |
|
||||
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
|
||||
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
|
||||
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
|
||||
|
||||
@@ -16,6 +16,43 @@ def ntfy_configured(settings: Settings) -> bool:
|
||||
return bool(settings.ntfy_url and settings.ntfy_topic)
|
||||
|
||||
|
||||
def format_standup_text(data: dict) -> str:
|
||||
"""Automatic standup (issue #6): yesterday / today / blockers."""
|
||||
lines = [f"🗣️ Standup — {data['today_label']}"]
|
||||
closed = data["closed_yesterday"]
|
||||
lines.append(
|
||||
f"\n✅ Hier ({data['yesterday_label']}) : {len(closed)} fermée(s), "
|
||||
f"{data['moved_yesterday']} déplacement(s)"
|
||||
)
|
||||
for item in closed[:8]:
|
||||
lines.append(
|
||||
f" • {item['repo']} #{item['number']} « {item['title']} »"
|
||||
)
|
||||
lines.append("\n📌 Aujourd'hui :")
|
||||
if data["picks"]:
|
||||
for pick in data["picks"]:
|
||||
lines.append(
|
||||
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
|
||||
f" — {pick['column_label']}"
|
||||
)
|
||||
elif data["focus_titles"]:
|
||||
for title in data["focus_titles"][:5]:
|
||||
lines.append(f" • {title}")
|
||||
lines.append(" (aucun focus épinglé — pense à /today)")
|
||||
else:
|
||||
lines.append(" • Rien en cours. Journée libre ou triage à faire !")
|
||||
lines.append("\n🚧 Blocages :")
|
||||
if data["blockers"]:
|
||||
for card in data["blockers"][:6]:
|
||||
lines.append(
|
||||
f" • {card['repo']} #{card['number']} « {card['title']} »"
|
||||
f" — {card['days']} j en {card['column_label']}"
|
||||
)
|
||||
else:
|
||||
lines.append(" • Aucun blocage signalé. ✨")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_briefing_text(data: dict) -> str:
|
||||
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||||
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
||||
@@ -27,6 +64,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']}")
|
||||
@@ -51,6 +104,8 @@ def format_briefing_text(data: dict) -> str:
|
||||
|
||||
if data["focus_total"] == 0 and not data["aging"]:
|
||||
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||||
if data.get("standup"):
|
||||
lines.append("\n" + data["standup"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Code links (roadmap #1): what code belongs to a card?
|
||||
|
||||
Pure matching helpers — a branch, pull request or commit is linked
|
||||
to an issue when it references the issue number the way developers
|
||||
actually write it: `#12` in a title/body/message, or the number as
|
||||
a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
|
||||
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
|
||||
|
||||
|
||||
def extract_issue_refs(text: str | None) -> set[int]:
|
||||
"""All #n references in a text (titles, bodies, messages)."""
|
||||
if not text:
|
||||
return set()
|
||||
return {int(m.group(1)) for m in _REF_RE.finditer(text)}
|
||||
|
||||
|
||||
def branch_issue_number(branch_name: str | None) -> int | None:
|
||||
"""The issue number encoded in a branch name, if any."""
|
||||
if not branch_name:
|
||||
return None
|
||||
m = _BRANCH_TOKEN_RE.search(branch_name)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def slugify_title(title: str, max_len: int = 40) -> str:
|
||||
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
|
||||
normalized = unicodedata.normalize("NFKD", title or "")
|
||||
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
|
||||
slug = re.sub(r"-{2,}", "-", slug)
|
||||
if len(slug) > max_len:
|
||||
slug = slug[:max_len].rstrip("-")
|
||||
return slug or "issue"
|
||||
|
||||
|
||||
def branch_name_for(number: int, title: str) -> str:
|
||||
"""The branch a card would create (issue #3): issue-<n>-<slug>.
|
||||
|
||||
The name is recognized back by branch_issue_number, which is
|
||||
what links the future PR to this card (issue #2).
|
||||
"""
|
||||
return f"issue-{number}-{slugify_title(title)}"
|
||||
|
||||
|
||||
def find_code_links(
|
||||
branches: list[dict],
|
||||
pulls: list[dict],
|
||||
commits: list[dict],
|
||||
number: int,
|
||||
) -> dict:
|
||||
"""Linked branches/PRs/commits for one issue number."""
|
||||
linked_branches = [
|
||||
b.get("name", "")
|
||||
for b in branches
|
||||
if branch_issue_number(b.get("name")) == number
|
||||
]
|
||||
linked_pulls = []
|
||||
for pr in pulls:
|
||||
head_ref = ((pr.get("head") or {}).get("ref")) or ""
|
||||
refs = extract_issue_refs(pr.get("title")) | extract_issue_refs(
|
||||
pr.get("body")
|
||||
)
|
||||
if number in refs or branch_issue_number(head_ref) == number:
|
||||
linked_pulls.append(
|
||||
{
|
||||
"number": pr.get("number"),
|
||||
"title": pr.get("title", ""),
|
||||
"state": pr.get("state", "open"),
|
||||
"merged": bool(pr.get("merged")),
|
||||
"html_url": pr.get("html_url", ""),
|
||||
"branch": head_ref,
|
||||
}
|
||||
)
|
||||
linked_commits = []
|
||||
for commit in commits:
|
||||
message = ((commit.get("commit") or {}).get("message")) or ""
|
||||
if number in extract_issue_refs(message):
|
||||
linked_commits.append(
|
||||
{
|
||||
"sha": (commit.get("sha") or "")[:7],
|
||||
"message": message.splitlines()[0] if message else "",
|
||||
"html_url": commit.get("html_url", ""),
|
||||
"date": (
|
||||
((commit.get("commit") or {}).get("author") or {}).get(
|
||||
"date", ""
|
||||
)
|
||||
or ""
|
||||
)[:10],
|
||||
}
|
||||
)
|
||||
# Best PR to badge: a merged one beats an open one beats closed.
|
||||
pr_badge = None
|
||||
for wanted in ("merged", "open", "closed"):
|
||||
for pr in linked_pulls:
|
||||
state = (
|
||||
"merged" if pr["merged"]
|
||||
else "open" if pr["state"] == "open" else "closed"
|
||||
)
|
||||
if state == wanted:
|
||||
pr_badge = {
|
||||
"number": pr["number"],
|
||||
"state": state,
|
||||
"label": {
|
||||
"merged": "fusionnée",
|
||||
"open": "ouverte",
|
||||
"closed": "fermée",
|
||||
}[state],
|
||||
}
|
||||
break
|
||||
if pr_badge:
|
||||
break
|
||||
return {
|
||||
"branches": linked_branches,
|
||||
"pulls": linked_pulls,
|
||||
"commits": linked_commits,
|
||||
"summary": {
|
||||
"branches": len(linked_branches),
|
||||
"commits": len(linked_commits),
|
||||
"pr": pr_badge,
|
||||
},
|
||||
}
|
||||
+20
-1
@@ -68,6 +68,19 @@ Environment variables:
|
||||
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
|
||||
"{title}" and "{content}" placeholders
|
||||
are substituted (JSON-escaped).
|
||||
KANBANTE_BRIEFING_INCLUDE_STANDUP
|
||||
"1" appends the automatic standup
|
||||
(issue #6) to the morning briefing.
|
||||
KANBANTE_RULES Automation rules (issue #8) as a JSON
|
||||
array: [{"name": ..., "when":
|
||||
{"column": "review", "min_age_days": 3},
|
||||
"then": {"notify": true}}]. Conditions:
|
||||
column, min_age_days, label, wip_over.
|
||||
Actions: add_label, notify, move_top.
|
||||
A rule can never close an issue.
|
||||
KANBANTE_ZOMBIE_DAYS Days without any activity after
|
||||
which an open card is a "zombie"
|
||||
on /health (issue #9). Default 30.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -76,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.9.0"
|
||||
APP_VERSION = "0.15.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -142,6 +155,9 @@ class Settings:
|
||||
obsigate_mcp_token: str = ""
|
||||
obsigate_mcp_tool: str = ""
|
||||
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||
briefing_include_standup: bool = False
|
||||
rules_raw: str = ""
|
||||
zombie_days: int = 30
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -184,4 +200,7 @@ def get_settings() -> Settings:
|
||||
"KANBANTE_OBSIGATE_MCP_ARGS",
|
||||
'{"title": "{title}", "content": "{content}"}',
|
||||
),
|
||||
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
|
||||
)
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.9.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.15.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
"""Flow health (roadmap #9): bottlenecks and zombie cards.
|
||||
|
||||
Boards show state; this module reads the event journal for health:
|
||||
how long cards actually stay in each column (dwell samples are the
|
||||
intervals between an entry and the next move/close), and which
|
||||
open cards have had no activity for a long time (zombies).
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
|
||||
def parse_ts(raw) -> datetime | None:
|
||||
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text[:19], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.strptime(text[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
|
||||
"""Dwell samples (in days) per column, rebuilt from the journal.
|
||||
|
||||
A stay starts when a card enters a column (created / moved /
|
||||
reopened) and ends at its next move or close. The first stay of
|
||||
a card whose journal starts mid-flight is unknowable and is
|
||||
simply not sampled.
|
||||
"""
|
||||
by_issue: dict[tuple[str, int], list[dict]] = {}
|
||||
for event in events:
|
||||
by_issue.setdefault(
|
||||
(event["repo_full_name"], event["issue_number"]), []
|
||||
).append(event)
|
||||
dwell: dict[str, list[float]] = {}
|
||||
for issue_events in by_issue.values():
|
||||
ordered = sorted(
|
||||
issue_events, key=lambda e: str(e.get("created_at") or "")
|
||||
)
|
||||
current: str | None = None
|
||||
since: datetime | None = None
|
||||
for event in ordered:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
kind = event["event_type"]
|
||||
if kind == "created":
|
||||
if current is None:
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
elif kind == "moved":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
elif current is None and event.get("from_column"):
|
||||
# The stay in from_column began before the journal
|
||||
# window: only the new stay can be timed.
|
||||
pass
|
||||
current = event.get("to_column") or current
|
||||
since = at
|
||||
elif kind == "closed":
|
||||
if current is not None and since is not None:
|
||||
dwell.setdefault(current, []).append(
|
||||
(at - since).total_seconds() / 86400
|
||||
)
|
||||
current = "done"
|
||||
since = at
|
||||
elif kind == "reopened":
|
||||
current = event.get("to_column") or "backlog"
|
||||
since = at
|
||||
return dwell
|
||||
|
||||
|
||||
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
|
||||
"""Median dwell per column, in board order."""
|
||||
summary = []
|
||||
for column in KANBAN_COLUMNS:
|
||||
samples = dwell.get(column) or []
|
||||
if not samples:
|
||||
continue
|
||||
summary.append(
|
||||
{
|
||||
"column": column,
|
||||
"count": len(samples),
|
||||
"median_days": round(statistics.median(samples), 1),
|
||||
"avg_days": round(sum(samples) / len(samples), 1),
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
|
||||
"""The bottleneck: slowest column (done excluded) with data."""
|
||||
candidates = [
|
||||
row for row in summary
|
||||
if row["column"] != "done" and row["count"] >= min_samples
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda r: r["median_days"])
|
||||
|
||||
|
||||
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
|
||||
"""Most recent journal timestamp per (repo, issue)."""
|
||||
latest: dict[tuple[str, int], datetime] = {}
|
||||
for event in events:
|
||||
at = parse_ts(event.get("created_at"))
|
||||
if at is None:
|
||||
continue
|
||||
key = (event["repo_full_name"], event["issue_number"])
|
||||
if key not in latest or at > latest[key]:
|
||||
latest[key] = at
|
||||
return latest
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
"""Forecasts (roadmap #7): honest ETAs from the event journal.
|
||||
|
||||
Throughput = issues closed per week, counted from board_events.
|
||||
Projections are deliberately given as a RANGE (best/worst weekly
|
||||
rate over the last 4 complete weeks) rather than a fake-precise
|
||||
date, and are withheld entirely when the journal has no closures
|
||||
to learn from — "not enough history" is an answer, too.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
|
||||
def _journal_today() -> date:
|
||||
"""'Today' in the journal's frame: board_events stamps are UTC
|
||||
(SQLite datetime('now')), so week buckets anchor to UTC —
|
||||
otherwise closures logged in the local evening (already
|
||||
tomorrow in UTC) fall outside every bucket."""
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
MONTHS_FR = [
|
||||
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||
]
|
||||
MONTHS_FR_SHORT = [
|
||||
"janv.", "févr.", "mars", "avr.", "mai", "juin",
|
||||
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
|
||||
]
|
||||
|
||||
|
||||
def french_date(d: date, short: bool = False) -> str:
|
||||
months = MONTHS_FR_SHORT if short else MONTHS_FR
|
||||
return f"{d.day} {months[d.month - 1]}"
|
||||
|
||||
|
||||
def _event_date(event: dict) -> date | None:
|
||||
raw = str(event.get("created_at") or "")
|
||||
try:
|
||||
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def weekly_closures(events: list[dict], weeks: int = 8,
|
||||
today: date | None = None) -> list[dict]:
|
||||
"""Closure counts per ISO week, oldest first, current week last."""
|
||||
today = today or _journal_today()
|
||||
current_monday = today - timedelta(days=today.weekday())
|
||||
buckets: list[dict] = []
|
||||
for i in range(weeks - 1, -1, -1):
|
||||
start = current_monday - timedelta(weeks=i)
|
||||
buckets.append({"start": start, "count": 0})
|
||||
by_start = {b["start"]: b for b in buckets}
|
||||
for event in events:
|
||||
if event.get("event_type") != "closed":
|
||||
continue
|
||||
day = _event_date(event)
|
||||
if day is None:
|
||||
continue
|
||||
monday = day - timedelta(days=day.weekday())
|
||||
if monday in by_start:
|
||||
by_start[monday]["count"] += 1
|
||||
return buckets
|
||||
|
||||
|
||||
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
|
||||
"""avg/min/max closures per week over the last COMPLETE weeks.
|
||||
|
||||
The current (partial) week is excluded: on Monday morning it
|
||||
would crush the average for no reason. Returns None when the
|
||||
complete weeks contain no closure at all.
|
||||
"""
|
||||
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
|
||||
if not complete:
|
||||
return None
|
||||
counts = [b["count"] for b in complete]
|
||||
if sum(counts) == 0:
|
||||
return None
|
||||
return {
|
||||
"avg": sum(counts) / len(counts),
|
||||
"min": min(counts),
|
||||
"max": max(counts),
|
||||
"weeks": len(counts),
|
||||
}
|
||||
|
||||
|
||||
def estimate(remaining: int, weekly: list[dict],
|
||||
today: date | None = None) -> dict | None:
|
||||
"""Projected completion for `remaining` items at the real pace.
|
||||
|
||||
Returns {"done": True} when nothing remains, None when there is
|
||||
no throughput to project from, else dates: central (avg rate),
|
||||
earliest (best week), latest (worst nonzero week, if any).
|
||||
"""
|
||||
today = today or _journal_today()
|
||||
if remaining <= 0:
|
||||
return {"done": True}
|
||||
stats = throughput_stats(weekly)
|
||||
if stats is None:
|
||||
return None
|
||||
|
||||
def at_rate(rate: float) -> date:
|
||||
days = remaining / rate * 7
|
||||
return today + timedelta(days=max(1, round(days)))
|
||||
|
||||
result = {
|
||||
"done": False,
|
||||
"avg_per_week": round(stats["avg"], 1),
|
||||
"central": at_rate(stats["avg"]),
|
||||
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
|
||||
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
|
||||
"""One-line French label for banners and cards (or None)."""
|
||||
if est is None:
|
||||
return None
|
||||
if est.get("done"):
|
||||
return f"✅ {done_text}"
|
||||
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
|
||||
if est.get("earliest") and est.get("latest"):
|
||||
label += (
|
||||
f" (fourchette {french_date(est['earliest'], short=True)}"
|
||||
f" – {french_date(est['latest'], short=True)})"
|
||||
)
|
||||
return label
|
||||
@@ -340,3 +340,47 @@ class GiteaClient:
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
"""Create a branch from another one (issue #3)."""
|
||||
return await self._request(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/branches",
|
||||
json={
|
||||
"new_branch_name": new_branch,
|
||||
"old_branch_name": old_branch,
|
||||
},
|
||||
)
|
||||
|
||||
async def list_branches(self, owner: str, repo: str) -> list[dict]:
|
||||
"""All branches of a repo (code links, issue #1)."""
|
||||
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
|
||||
|
||||
async def list_pulls(
|
||||
self, owner: str, repo: str, state: str = "all"
|
||||
) -> list[dict]:
|
||||
"""All pull requests of a repo (code links, issue #1)."""
|
||||
return await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/pulls", state=state
|
||||
)
|
||||
|
||||
async def list_commits(
|
||||
self, owner: str, repo: str, limit: int = 75
|
||||
) -> list[dict]:
|
||||
"""Most recent commits of a repo (code links, issue #1)."""
|
||||
data = await self._get(f"/repos/{owner}/{repo}/commits", limit=limit)
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def set_issue_assignees(
|
||||
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
||||
) -> dict:
|
||||
"""Replace the assignee set of an issue (triage "assign me")."""
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
||||
json={"assignees": logins},
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
+1105
-3
File diff suppressed because it is too large
Load Diff
+37
-1
@@ -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(
|
||||
@@ -258,7 +294,7 @@ def set_kv(key: str, value: str) -> None:
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||
|
||||
|
||||
def log_event(
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
"""Automation rules (roadmap #8): small declarative IFTTT for the board.
|
||||
|
||||
Rules live in KANBANTE_RULES (JSON array) — deliberately boring:
|
||||
|
||||
[{"name": "Relance révision",
|
||||
"when": {"column": "review", "min_age_days": 3},
|
||||
"then": {"notify": true}},
|
||||
{"name": "Urgent qui vieillit",
|
||||
"when": {"column": "in_progress", "min_age_days": 7},
|
||||
"then": {"add_label": "urgent"}}]
|
||||
|
||||
Conditions: column, min_age_days (time in the current column),
|
||||
label (present on the card), wip_over (column holds MORE cards
|
||||
than N — then every card of the column matches).
|
||||
Actions: add_label, notify (ntfy), move_top (of its column).
|
||||
|
||||
Guardrail, by design: there is NO close/delete/move action — a
|
||||
rule can never close an issue or move a card between columns.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
|
||||
ACTION_KEYS = {"add_label", "notify", "move_top"}
|
||||
|
||||
|
||||
def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
|
||||
"""Validate KANBANTE_RULES; returns (rules, human errors)."""
|
||||
if not raw or not raw.strip():
|
||||
return [], []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
return [], [f"JSON invalide : {exc}"]
|
||||
if not isinstance(data, list):
|
||||
return [], ["KANBANTE_RULES doit être un tableau JSON de règles"]
|
||||
rules: list[dict] = []
|
||||
errors: list[str] = []
|
||||
for i, item in enumerate(data):
|
||||
label = f"règle #{i + 1}"
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"{label} : pas un objet")
|
||||
continue
|
||||
name = str(item.get("name") or label)
|
||||
when = item.get("when")
|
||||
then = item.get("then")
|
||||
if not isinstance(when, dict) or not isinstance(then, dict):
|
||||
errors.append(f"{name} : 'when' et 'then' (objets) sont requis")
|
||||
continue
|
||||
bad_when = set(when) - CONDITION_KEYS
|
||||
bad_then = set(then) - ACTION_KEYS
|
||||
if bad_when:
|
||||
errors.append(f"{name} : conditions inconnues {sorted(bad_when)}")
|
||||
continue
|
||||
if bad_then:
|
||||
errors.append(
|
||||
f"{name} : actions inconnues {sorted(bad_then)} "
|
||||
"(fermer/déplacer une carte par règle est interdit en v1)"
|
||||
)
|
||||
continue
|
||||
if "column" in when and when["column"] not in KANBAN_COLUMNS:
|
||||
errors.append(f"{name} : colonne inconnue « {when['column']} »")
|
||||
continue
|
||||
if "min_age_days" in when and (
|
||||
not isinstance(when["min_age_days"], int) or when["min_age_days"] < 0
|
||||
):
|
||||
errors.append(f"{name} : min_age_days doit être un entier ≥ 0")
|
||||
continue
|
||||
if "wip_over" in when and (
|
||||
not isinstance(when["wip_over"], int) or when["wip_over"] < 1
|
||||
):
|
||||
errors.append(f"{name} : wip_over doit être un entier ≥ 1")
|
||||
continue
|
||||
if "add_label" in then and (
|
||||
not isinstance(then["add_label"], str) or not then["add_label"].strip()
|
||||
):
|
||||
errors.append(f"{name} : add_label doit être un nom d'étiquette")
|
||||
continue
|
||||
if not then:
|
||||
errors.append(f"{name} : aucune action")
|
||||
continue
|
||||
rules.append({"name": name, "when": when, "then": then})
|
||||
return rules, errors
|
||||
|
||||
|
||||
def matching_cards(
|
||||
rule: dict,
|
||||
columns: dict[str, list[dict]],
|
||||
ages: dict[int, dict],
|
||||
) -> list[tuple[dict, str]]:
|
||||
"""Cards of `columns` (merged board) matched by the rule."""
|
||||
when = rule["when"]
|
||||
targets = [when["column"]] if "column" in when else list(columns)
|
||||
matches: list[tuple[dict, str]] = []
|
||||
for column in targets:
|
||||
cards = columns.get(column, [])
|
||||
if "wip_over" in when and len(cards) <= when["wip_over"]:
|
||||
continue
|
||||
for issue in cards:
|
||||
if "label" in when and when["label"] not in [
|
||||
(lab or {}).get("name") for lab in issue.get("labels") or []
|
||||
]:
|
||||
continue
|
||||
if "min_age_days" in when:
|
||||
age = ages.get(issue["number"]) or {}
|
||||
if (age.get("days") or 0) < when["min_age_days"]:
|
||||
continue
|
||||
matches.append((issue, column))
|
||||
return matches
|
||||
@@ -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)
|
||||
@@ -19,6 +19,9 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if issue._code %}
|
||||
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
|
||||
{% endif %}
|
||||
<div class="card-meta">
|
||||
{% if issue.assignee %}
|
||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||
@@ -29,5 +32,6 @@
|
||||
<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,63 @@
|
||||
{# ── Header global partagé (issue #16) ─────────────────────────────
|
||||
NE PAS dupliquer ce markup dans les pages : c'est l'unique source.
|
||||
Page active : variable `active_nav` (déduite du gabarit dans
|
||||
render(), app/main.py). Fil d'Ariane projet : `owner`+`repo`
|
||||
(board) ou `full_name` (pages projet). Lien Gitea externe si
|
||||
`repo_info.html_url` est présent (board).
|
||||
Desktop : primaires + menu « Plus ▾ ». Mobile : burger + panneau.
|
||||
Le wallboard (TV) n'inclut pas ce partiel, volontairement. #}
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
{% if owner is defined and repo is defined %}
|
||||
<span class="crumb"><span class="sep">/</span> <a href="/board/{{ owner }}/{{ repo }}">{{ owner }} / <strong>{{ repo }}</strong></a></span>
|
||||
{% elif full_name is defined %}
|
||||
<span class="crumb"><span class="sep">/</span> <a href="/board/{{ full_name }}">{{ full_name }}</a></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<nav class="nav-pills main-nav" aria-label="Navigation principale">
|
||||
<a href="/" class="nav-pill{% if active_nav == 'home' %} active{% endif %}">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill{% if active_nav == 'focus' %} active{% endif %}">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill{% if active_nav == 'today' %} active{% endif %}">🌅 Aujourd'hui</a>
|
||||
<a href="/search" class="nav-pill{% if active_nav == 'search' %} active{% endif %}">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill{% if active_nav == 'capture' %} active{% endif %}">⚡ Capture</a>
|
||||
<div class="nav-more">
|
||||
<button type="button" class="nav-pill nav-more-btn{% if active_nav in ['my', 'activity', 'briefing', 'standup', 'digest', 'forecast', 'rules', 'health'] %} active-soft{% endif %}" id="nav-more-btn" aria-expanded="false" aria-controls="nav-more-menu">Plus <span aria-hidden="true">▾</span></button>
|
||||
<div class="nav-more-menu" id="nav-more-menu" role="menu" hidden>
|
||||
<a href="/my" class="nav-pill{% if active_nav == 'my' %} active{% endif %}" role="menuitem">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill{% if active_nav == 'activity' %} active{% endif %}" role="menuitem">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill{% if active_nav == 'briefing' %} active{% endif %}" role="menuitem">📊 Briefing</a>
|
||||
<a href="/standup" class="nav-pill{% if active_nav == 'standup' %} active{% endif %}" role="menuitem">🗣️ Standup</a>
|
||||
<a href="/digest" class="nav-pill{% if active_nav == 'digest' %} active{% endif %}" role="menuitem">🧠 Digest</a>
|
||||
<a href="/forecast" class="nav-pill{% if active_nav == 'forecast' %} active{% endif %}" role="menuitem">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill{% if active_nav == 'rules' %} active{% endif %}" role="menuitem">⚙️ Règles</a>
|
||||
<a href="/health" class="nav-pill{% if active_nav == 'health' %} active{% endif %}" role="menuitem">🩺 Santé</a>
|
||||
<a href="/wall" class="nav-pill" role="menuitem">🖥️ Wallboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
{% if repo_info is defined and repo_info and repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" rel="noopener" class="nav-pill" title="Ouvrir ce dépôt dans Gitea">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
<button type="button" class="nav-burger" id="nav-burger" aria-expanded="false" aria-controls="mobile-nav" aria-label="Ouvrir le menu">☰</button>
|
||||
</div>
|
||||
<nav class="mobile-nav" id="mobile-nav" aria-label="Menu mobile" hidden>
|
||||
<a href="/" class="{% if active_nav == 'home' %}active{% endif %}">🏠 Projets</a>
|
||||
<a href="/focus" class="{% if active_nav == 'focus' %}active{% endif %}">🎯 Focus</a>
|
||||
<a href="/today" class="{% if active_nav == 'today' %}active{% endif %}">🌅 Aujourd'hui</a>
|
||||
<a href="/search" class="{% if active_nav == 'search' %}active{% endif %}">🔍 Recherche</a>
|
||||
<a href="/capture" class="{% if active_nav == 'capture' %}active{% endif %}">⚡ Capture</a>
|
||||
<a href="/my" class="{% if active_nav == 'my' %}active{% endif %}">👤 Mes issues</a>
|
||||
<a href="/activity" class="{% if active_nav == 'activity' %}active{% endif %}">📰 Activité</a>
|
||||
<a href="/briefing" class="{% if active_nav == 'briefing' %}active{% endif %}">📊 Briefing</a>
|
||||
<a href="/standup" class="{% if active_nav == 'standup' %}active{% endif %}">🗣️ Standup</a>
|
||||
<a href="/digest" class="{% if active_nav == 'digest' %}active{% endif %}">🧠 Digest</a>
|
||||
<a href="/forecast" class="{% if active_nav == 'forecast' %}active{% endif %}">🔮 Prévisions</a>
|
||||
<a href="/rules" class="{% if active_nav == 'rules' %}active{% endif %}">⚙️ Règles</a>
|
||||
<a href="/health" class="{% if active_nav == 'health' %}active{% endif %}">🩺 Santé</a>
|
||||
<a href="/wall">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
<script src="/static/js/nav.js" defer></script>
|
||||
</header>
|
||||
@@ -8,24 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
|
||||
|
||||
@@ -12,26 +12,7 @@
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
{% if milestones %}
|
||||
<div class="milestone-bar">
|
||||
@@ -51,6 +32,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>
|
||||
@@ -67,6 +52,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>
|
||||
@@ -157,6 +143,7 @@
|
||||
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
||||
<div id="detail-label-toggles" class="label-toggles"></div>
|
||||
</div>
|
||||
<div id="detail-code" class="detail-code"></div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<div id="detail-comments"></div>
|
||||
@@ -175,6 +162,7 @@
|
||||
{% endif %}
|
||||
</footer>
|
||||
<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>
|
||||
|
||||
@@ -9,23 +9,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
|
||||
|
||||
@@ -8,21 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -9,23 +9,7 @@
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
@@ -86,6 +70,9 @@
|
||||
{% 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','qa','review','done'] %}
|
||||
|
||||
@@ -8,21 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
|
||||
@@ -7,18 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
<main class="error-main">
|
||||
<div class="error-box">
|
||||
<h2>⚠️ Quelque chose s'est mal passé</h2>
|
||||
|
||||
@@ -8,25 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill active">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Tout ce qui est <strong>En cours</strong> ou <strong>En révision</strong>, tous projets actifs confondus.</p>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Prévisions — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||
<p>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>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Santé du flux — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</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>Santé — {{ full_name }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if health is none %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
<p>Projet introuvable.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
<h2>⏱️ Temps passé par colonne (journal)</h2>
|
||||
{% if health.dwell %}
|
||||
<table class="health-table">
|
||||
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in health.dwell %}
|
||||
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
|
||||
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
<td>{{ row.median_days }} j</td>
|
||||
<td>{{ row.avg_days }} j</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if health.slowest %}
|
||||
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
|
||||
{% if health.zombies %}
|
||||
<ul class="today-list">
|
||||
{% for z in health.zombies %}
|
||||
<li>
|
||||
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
|
||||
<span class="zombie-actions">
|
||||
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
|
||||
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
|
||||
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
|
||||
{% else %}
|
||||
<p>Aucune carte zombie. Le flux est propre. ✨</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/health.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-17
@@ -8,23 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if not user %}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Règles d'automatisation — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚙️ Règles d'automatisation</h1>
|
||||
<p>Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
|
||||
{% if not configured %}
|
||||
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
|
||||
<pre class="briefing-text">[{"name": "Relance révision",
|
||||
"when": {"column": "review", "min_age_days": 3},
|
||||
"then": {"notify": true}},
|
||||
{"name": "Urgent qui vieillit",
|
||||
"when": {"column": "in_progress", "min_age_days": 7},
|
||||
"then": {"add_label": "urgent"}}]</pre>
|
||||
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
|
||||
{% endif %}
|
||||
{% for err in errors %}
|
||||
<p class="chip-warn">⚠️ {{ err }}</p>
|
||||
{% endfor %}
|
||||
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
|
||||
<span id="rules-run-result" class="notes-hint"></span></p>
|
||||
</section>
|
||||
|
||||
{% if rules %}
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Règles actives ({{ rules|length }})</h2>
|
||||
<ul class="forecast-milestones">
|
||||
{% for rule in rules %}
|
||||
<li><strong>{{ rule.name }}</strong> — quand
|
||||
{% for k, v in rule.when.items() %}<code>{{ k }} = {{ v }}</code>{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
→ {% for k, v in rule.then.items() %}<code>{{ k }}{% if v is not boolean %} = {{ v }}{% endif %}</code>{% if not loop.last %}, {% endif %}{% endfor %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if last_run %}
|
||||
<section class="briefing-card">
|
||||
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
|
||||
{% if last_run.actions %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for action in last_run.actions %}
|
||||
<li><strong>{{ action.rule }}</strong> : {{ action.repo }} #{{ action.number }} ({{ action.column }}) — {{ action.did|join(', ') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Aucune action déclenchée.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'automatisation maison, près des données, jamais destructive</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('rules-run').addEventListener('click', async () => {
|
||||
const out = document.getElementById('rules-run-result');
|
||||
out.textContent = 'Évaluation en cours…';
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||
const data = new FormData();
|
||||
data.append('csrf_token', csrf);
|
||||
try {
|
||||
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
|
||||
const body = await r.json();
|
||||
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
|
||||
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
|
||||
setTimeout(() => location.reload(), 700);
|
||||
} catch (e) {
|
||||
out.textContent = '❌ ' + e.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,23 +8,7 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<form method="get" action="/search" class="search-hero">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Récit de sprint — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if markdown %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 {{ milestone_title }}</h1>
|
||||
<p>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
|
||||
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
|
||||
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
|
||||
</p>
|
||||
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
|
||||
</section>
|
||||
<section class="briefing-card sprint-render" id="sprint-render"></section>
|
||||
<script>
|
||||
const SPRINT_MD = {{ markdown|tojson }};
|
||||
document.getElementById('sprint-render').innerHTML =
|
||||
DOMPurify.sanitize(marked.parse(SPRINT_MD));
|
||||
document.getElementById('sprint-copy').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(SPRINT_MD);
|
||||
document.getElementById('sprint-copy').textContent = '✅ Copié !';
|
||||
} catch (e) { alert('Copie impossible : ' + e); }
|
||||
});
|
||||
document.getElementById('sprint-download').addEventListener('click', () => {
|
||||
const blob = new Blob([SPRINT_MD], { type: 'text/markdown' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'recit-{{ full_name|replace('/', '-') }}-{{ milestone_param|urlencode }}.md';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<h1>📖 Récit de sprint — {{ full_name }}</h1>
|
||||
<p>Choisis un milestone pour générer son récit :</p>
|
||||
<ul>
|
||||
{% for m in milestones %}
|
||||
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
|
||||
{% else %}
|
||||
<li>Aucun milestone dans ce projet.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · le récit est écrit par le journal, pas par la mémoire des gens</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Standup — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🗣️ Standup automatique</h1>
|
||||
<p>Écrit par le journal, pas par la mémoire : fermé/déplacé <strong>hier</strong>, focus <strong>aujourd'hui</strong>, cartes qui vieillissent en QA/Révision en <strong>blocages</strong>.</p>
|
||||
<p>
|
||||
<button type="button" class="btn-save btn-inline" id="standup-copy">📋 Copier le standup</button>
|
||||
{% if include_in_briefing %}
|
||||
<span class="chip-ok">Inclus dans le briefing ntfy du matin ✔</span>
|
||||
{% else %}
|
||||
<span class="notes-hint">Astuce : <code>KANBANTE_BRIEFING_INCLUDE_STANDUP=1</code> l'ajoute au briefing ntfy du matin.</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
<section class="briefing-card">
|
||||
<pre class="briefing-text" id="standup-text">{{ text }}</pre>
|
||||
</section>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · zéro saisie, toujours factuel, daté</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('standup-copy').addEventListener('click', async () => {
|
||||
const text = document.getElementById('standup-text').textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
document.getElementById('standup-copy').textContent = '✅ Copié !';
|
||||
} catch (e) { alert('Copie impossible : ' + e); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Aujourd'hui — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="today-main">
|
||||
<h1 class="today-title">🌅 {{ date_label }}</h1>
|
||||
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
|
||||
|
||||
{% if picks %}
|
||||
<section class="today-picks">
|
||||
{% for pick in picks %}
|
||||
<article class="today-card{% if pick.state == 'closed' %} done{% endif %}">
|
||||
<div class="today-card-top">
|
||||
<span class="today-card-repo">{{ pick.repo }}</span>
|
||||
<span class="today-card-col">{{ pick.column_label }}</span>
|
||||
</div>
|
||||
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
|
||||
<div class="today-card-actions">
|
||||
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
|
||||
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
|
||||
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="briefing-card">
|
||||
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if recap %}
|
||||
<section class="briefing-card">
|
||||
<h2>🌆 Ça a bougé aujourd'hui</h2>
|
||||
<ul>
|
||||
{% for event in recap %}
|
||||
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if carry_over %}
|
||||
<section class="briefing-card">
|
||||
<h2>↩️ À reprendre d'hier</h2>
|
||||
<ul class="today-list">
|
||||
{% for pick in carry_over %}
|
||||
<li>
|
||||
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
|
||||
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if candidates and picks|length < 3 %}
|
||||
<section class="briefing-card">
|
||||
<h2>🎯 Candidates (encours actuel)</h2>
|
||||
<ul class="today-list">
|
||||
{% for card in candidates %}
|
||||
<li>
|
||||
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
|
||||
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · le focus du jour se réinitialise chaque matin — les cartes non terminées reviennent en proposition</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/today.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Triage — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>📥 Mode Triage</h1>
|
||||
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<div class="triage-projects">
|
||||
{% for p in projects %}
|
||||
<a class="triage-project" href="/triage/{{ p.full_name }}">
|
||||
<span class="triage-project-name">📂 {{ p.full_name }}</span>
|
||||
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Triage {{ full_name }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="triage-main" data-repo="{{ full_name }}">
|
||||
<div class="triage-head">
|
||||
<h1>📥 Triage — {{ full_name }}</h1>
|
||||
<div class="triage-progress-wrap">
|
||||
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
|
||||
<span class="triage-progress-label" id="triage-progress-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="triage-card" id="triage-card" aria-live="polite">
|
||||
<div class="triage-card-top">
|
||||
<span class="triage-card-num" id="tc-num"></span>
|
||||
<span class="triage-card-date" id="tc-date"></span>
|
||||
<a class="triage-card-link" id="tc-link" href="#" target="_blank" rel="noopener">Ouvrir dans Gitea ↗</a>
|
||||
</div>
|
||||
<h2 class="triage-card-title" id="tc-title"></h2>
|
||||
<div class="triage-card-labels" id="tc-labels"></div>
|
||||
<p class="triage-card-excerpt" id="tc-excerpt"></p>
|
||||
<p class="triage-card-assign" id="tc-assign"></p>
|
||||
</section>
|
||||
|
||||
<section class="triage-done" id="triage-done" hidden>
|
||||
<h2>🎉 Session terminée</h2>
|
||||
<ul class="triage-summary" id="triage-summary"></ul>
|
||||
<p>
|
||||
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
|
||||
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="triage-status" id="triage-status" role="status"></div>
|
||||
|
||||
<section class="triage-keys" id="triage-keys">
|
||||
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
|
||||
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
|
||||
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
|
||||
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
|
||||
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
|
||||
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
|
||||
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
|
||||
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
|
||||
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
|
||||
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
|
||||
</section>
|
||||
|
||||
<div class="triage-labels" id="triage-labels" hidden>
|
||||
<div class="triage-labels-head">
|
||||
<strong>🏷️ Étiquettes de la carte</strong>
|
||||
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
|
||||
</div>
|
||||
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement</span>
|
||||
</footer>
|
||||
<script id="triage-queue" type="application/json">{{ queue_json|safe }}</script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/triage.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -58,6 +58,14 @@
|
||||
</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>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.9.0"
|
||||
version = "0.15.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1026,3 +1026,186 @@ footer {
|
||||
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); }
|
||||
|
||||
/* ── Code lié (0.13.0, #1) ── */
|
||||
.card-code { font-size: 0.78rem; color: var(--accent, #58a6ff); margin-top: 5px; }
|
||||
.detail-code { margin: 10px 0; }
|
||||
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
|
||||
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
|
||||
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
|
||||
|
||||
/* ── Branche depuis la carte (0.14.0, #3) ── */
|
||||
.branch-input {
|
||||
background: var(--surface-3, #1d2942); color: var(--text-1);
|
||||
border: 1px solid var(--border, #22304a); border-radius: 6px;
|
||||
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Santé du flux (0.14.0, #9) ── */
|
||||
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
|
||||
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
|
||||
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
|
||||
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
/* ── Header unifié (0.15.0, #16) ── */
|
||||
.main-nav { flex-wrap: nowrap; }
|
||||
.header-actions { display: flex; gap: 6px; align-items: center; margin-left: auto; }
|
||||
.nav-more { position: relative; }
|
||||
.nav-more-btn { background: transparent; cursor: pointer; }
|
||||
.nav-more-btn.active-soft { color: var(--text); border-color: var(--accent, #58a6ff); }
|
||||
.nav-more-menu {
|
||||
position: absolute; right: 0; top: calc(100% + 8px); z-index: 120;
|
||||
min-width: 232px; display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--surface-1, #131a29); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 6px;
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.nav-more-menu .nav-pill { display: flex; border-radius: 8px; }
|
||||
.nav-more-menu[hidden], .mobile-nav[hidden] { display: none !important; }
|
||||
.nav-burger {
|
||||
display: none; width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
color: var(--text); font-size: 1.05rem; cursor: pointer;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.mobile-nav {
|
||||
position: absolute; top: 100%; left: 0; right: 0; z-index: 110;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: rgba(16, 19, 28, 0.97); backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--border); padding: 10px 14px 16px;
|
||||
}
|
||||
.mobile-nav a {
|
||||
display: flex; align-items: center; min-height: 44px;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
color: var(--text-2); font-size: 1rem; text-decoration: none;
|
||||
}
|
||||
.mobile-nav a:hover { background: var(--surface-2); color: var(--text); text-decoration: none; }
|
||||
.mobile-nav a.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(124,92,252,.85), rgba(91,138,247,.85));
|
||||
}
|
||||
.crumb a { color: inherit; text-decoration: none; }
|
||||
.crumb a:hover { text-decoration: underline; }
|
||||
@media (max-width: 900px) {
|
||||
.main-nav { display: none; }
|
||||
.nav-burger { display: inline-flex; }
|
||||
.crumb { display: none; }
|
||||
}
|
||||
|
||||
@@ -369,12 +369,68 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
fillDetail(data.issue, data.comments);
|
||||
loadCodeLinks(repoFull, number);
|
||||
} catch (err) {
|
||||
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
||||
showToast('❌ Détail indisponible : ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadCodeLinks(repoFull, number) {
|
||||
const box = document.getElementById('detail-code');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
const [owner, repo] = repoFull.split('/', 2);
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) return;
|
||||
const parts = [];
|
||||
if ((data.branches || []).length) {
|
||||
parts.push('🌿 Branches : ' + data.branches.map(b => `<code>${b}</code>`).join(', '));
|
||||
}
|
||||
for (const pr of data.pulls || []) {
|
||||
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
|
||||
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
|
||||
}
|
||||
for (const c of (data.commits || []).slice(0, 8)) {
|
||||
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
|
||||
}
|
||||
const hasBranch = (data.branches || []).length > 0;
|
||||
if (!hasBranch && data.suggested_branch) {
|
||||
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
|
||||
}
|
||||
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
|
||||
parts.length
|
||||
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
||||
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
|
||||
);
|
||||
const createBtn = document.getElementById('branch-create');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', async () => {
|
||||
const name = (document.getElementById('branch-name') || {}).value || '';
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
form.append('csrf_token', csrfToken);
|
||||
createBtn.disabled = true;
|
||||
try {
|
||||
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
|
||||
const body = await r2.json().catch(() => ({}));
|
||||
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
|
||||
showToast(body.exists
|
||||
? `🌿 La branche ${body.branch} existait déjà`
|
||||
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
|
||||
loadCodeLinks(repoFull, number);
|
||||
} catch (err) {
|
||||
createBtn.disabled = false;
|
||||
showToast('❌ Branche non créée : ' + err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) { /* le code est un bonus, jamais bloquant */ }
|
||||
}
|
||||
|
||||
function fillDetail(issue, comments) {
|
||||
document.getElementById('detail-title').textContent = issue.title;
|
||||
const stateChip = document.getElementById('detail-state');
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
|
||||
* Close/backlog reuse /api/move; the nudge reuses the comments API.
|
||||
*/
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
function post(url, fields) {
|
||||
var data = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
|
||||
data.append('csrf_token', csrf);
|
||||
return fetch(url, { method: 'POST', body: data }).then(function (r) {
|
||||
return r.json().catch(function () { return {}; }).then(function (body) {
|
||||
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
|
||||
return body;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('[data-zombie]');
|
||||
if (!btn) return;
|
||||
var repo = btn.getAttribute('data-repo');
|
||||
var number = btn.getAttribute('data-number');
|
||||
var column = btn.getAttribute('data-column');
|
||||
var action = btn.getAttribute('data-zombie');
|
||||
var parts = repo.split('/');
|
||||
btn.disabled = true;
|
||||
var done = function () { location.reload(); };
|
||||
var fail = function (err) { btn.disabled = false; alert(err.message); };
|
||||
if (action === 'comment') {
|
||||
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
|
||||
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
|
||||
}).then(done).catch(fail);
|
||||
} else {
|
||||
post('/api/move', {
|
||||
repo_full_name: repo,
|
||||
issue_number: number,
|
||||
from_column: column,
|
||||
to_column: action === 'close' ? 'done' : 'backlog',
|
||||
to_order: number
|
||||
}).then(done).catch(fail);
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Kanbante — header navigation (issue #16): the "Plus" dropdown on
|
||||
* desktop and the burger panel on mobile. One shared partial
|
||||
* (_header.html), one shared behaviour: this file.
|
||||
*/
|
||||
(function () {
|
||||
var moreBtn = document.getElementById('nav-more-btn');
|
||||
var moreMenu = document.getElementById('nav-more-menu');
|
||||
var burger = document.getElementById('nav-burger');
|
||||
var mobileNav = document.getElementById('mobile-nav');
|
||||
|
||||
function setOpen(menu, btn, open) {
|
||||
if (!menu || !btn) return;
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
function closeAll() {
|
||||
setOpen(moreMenu, moreBtn, false);
|
||||
setOpen(mobileNav, burger, false);
|
||||
if (burger) {
|
||||
burger.textContent = '☰';
|
||||
burger.setAttribute('aria-label', 'Ouvrir le menu');
|
||||
}
|
||||
}
|
||||
|
||||
if (moreBtn && moreMenu) {
|
||||
moreBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var willOpen = moreMenu.hidden;
|
||||
closeAll();
|
||||
setOpen(moreMenu, moreBtn, willOpen);
|
||||
});
|
||||
moreMenu.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
if (burger && mobileNav) {
|
||||
burger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var willOpen = mobileNav.hidden;
|
||||
closeAll();
|
||||
setOpen(mobileNav, burger, willOpen);
|
||||
burger.textContent = willOpen ? '✕' : '☰';
|
||||
burger.setAttribute('aria-label', willOpen ? 'Fermer le menu' : 'Ouvrir le menu');
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function () { closeAll(); });
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAll();
|
||||
});
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth > 900) {
|
||||
closeAll();
|
||||
if (burger) burger.textContent = '☰';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -13,10 +13,16 @@
|
||||
var ACTIONS = [
|
||||
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
||||
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
|
||||
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
||||
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
|
||||
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
|
||||
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
|
||||
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
|
||||
{ icon: '📥', label: 'Mode Triage — vider un backlog au clavier', href: '/triage' },
|
||||
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
|
||||
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
|
||||
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
+80
-10
@@ -1,21 +1,90 @@
|
||||
/**
|
||||
* Kanbante — Wallboard: slide rotation, clock, live refresh.
|
||||
* Kanbante — Wallboard: rotation, manual navigation, clock, live.
|
||||
*
|
||||
* The wall is a display, not a workspace: no interaction to protect,
|
||||
* so a realtime event simply reloads the page (plus a slow safety
|
||||
* reload in case the stream drops silently).
|
||||
* 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 current = 0;
|
||||
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 () {
|
||||
slides[current].classList.remove('active');
|
||||
current = (current + 1) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
if (!paused && Date.now() >= pausedUntil) show(current + 1, false);
|
||||
}, ROTATE_MS);
|
||||
setInterval(updateBadge, 5000);
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
@@ -30,10 +99,11 @@
|
||||
tickClock();
|
||||
setInterval(tickClock, 15000);
|
||||
|
||||
// Live: any board activity refreshes the wall.
|
||||
// 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.onmessage = function () { location.reload(); };
|
||||
source.addEventListener('gitea', function () { location.reload(); });
|
||||
} catch (err) {
|
||||
console.warn('Wallboard live stream unavailable:', err);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
|
||||
"owner": {"login": "Projets"},
|
||||
"archived": archived,
|
||||
"html_url": f"http://gitea.test/Projets/{name}",
|
||||
"default_branch": "main",
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +73,7 @@ class FakeGitea:
|
||||
_repo("vieux", now - timedelta(days=800)),
|
||||
_repo("archive", now - timedelta(days=1), archived=True),
|
||||
]
|
||||
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
|
||||
self.issues = {
|
||||
"Projets/demo": [
|
||||
_issue(1, "Tâche un"),
|
||||
@@ -85,6 +87,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): [
|
||||
@@ -222,6 +225,51 @@ class FakeGitea:
|
||||
for label in removed:
|
||||
self.label_ops.append(("remove", number, label["name"]))
|
||||
|
||||
async def list_branches(self, owner, repo):
|
||||
return [{"name": n} for n in self.branches]
|
||||
|
||||
async def create_branch(self, owner, repo, new_branch, old_branch):
|
||||
if new_branch in self.branches:
|
||||
raise RuntimeError("branch exists")
|
||||
self.branches.append(new_branch)
|
||||
return {"name": new_branch}
|
||||
|
||||
async def list_pulls(self, owner, repo, state="all"):
|
||||
return [
|
||||
{
|
||||
"number": 16, "title": "Corrige la tâche un",
|
||||
"body": "Fixes #1", "state": "open", "merged": False,
|
||||
"html_url": "http://gitea.test/pr/16",
|
||||
"head": {"ref": "feature/1-tache-un"},
|
||||
},
|
||||
{
|
||||
"number": 17, "title": "Focus", "body": "",
|
||||
"state": "closed", "merged": True,
|
||||
"html_url": "http://gitea.test/pr/17",
|
||||
"head": {"ref": "issue-5-focus"},
|
||||
},
|
||||
]
|
||||
|
||||
async def list_commits(self, owner, repo, limit=75):
|
||||
return [
|
||||
{
|
||||
"sha": "abcdef1234567890",
|
||||
"html_url": "http://gitea.test/c/abcdef1",
|
||||
"commit": {
|
||||
"message": "Début de la tâche #1\n\nDétails",
|
||||
"author": {"date": "2026-10-10T10:00:00Z"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"sha": "1234567abcdef",
|
||||
"html_url": "http://gitea.test/c/1234567",
|
||||
"commit": {
|
||||
"message": "Commit sans rapport",
|
||||
"author": {"date": "2026-10-09T10:00:00Z"},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
{
|
||||
@@ -239,6 +287,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,202 @@
|
||||
"""Palier 10 (0.13.0): code lié (#1), PR fusionnée (#2),
|
||||
standup automatique (#6), règles d'automatisation (#8)."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import code_links as cl
|
||||
from app import models
|
||||
from app import rules as rules_mod
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── #1 Code lié : fonctions pures ─────────────────────────────────
|
||||
|
||||
|
||||
def test_extract_refs_and_branch_numbers():
|
||||
assert cl.extract_issue_refs("Fixes #12 et rapport #3, pas #x") == {12, 3}
|
||||
assert cl.extract_issue_refs("a#b #12") == {12}
|
||||
assert cl.extract_issue_refs(None) == set()
|
||||
assert cl.branch_issue_number("feature/12-login") == 12
|
||||
assert cl.branch_issue_number("issue-7") == 7
|
||||
assert cl.branch_issue_number("42") == 42
|
||||
assert cl.branch_issue_number("release-2026") == 2026
|
||||
assert cl.branch_issue_number("main") is None
|
||||
assert cl.branch_issue_number("hotfix/no-number") is None
|
||||
|
||||
|
||||
def test_code_endpoint(client, fake_gitea):
|
||||
r = client.get("/api/issues/Projets/demo/1/code")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["branches"] == ["feature/1-tache-un"]
|
||||
assert [p["number"] for p in data["pulls"]] == [16]
|
||||
assert [c["sha"] for c in data["commits"]] == ["abcdef1"]
|
||||
assert data["summary"]["pr"]["label"] == "ouverte"
|
||||
board = client.get("/board/Projets/demo")
|
||||
assert "PR #16 ouverte" in board.text
|
||||
assert "1 commit" in board.text
|
||||
|
||||
|
||||
# ── #2 PR fusionnée → carte Terminé ───────────────────────────────
|
||||
|
||||
|
||||
def _pr_payload(merged=True, title="Corrige tout", body="Fixes #1",
|
||||
ref="feature/1-tache-un"):
|
||||
return {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"pull_request": {
|
||||
"number": 16, "title": title, "body": body,
|
||||
"merged": merged, "head": {"ref": ref},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _post_webhook(client, payload, event="pull_request"):
|
||||
return client.post(
|
||||
"/webhooks/gitea", json=payload, headers={"X-Gitea-Event": event}
|
||||
)
|
||||
|
||||
|
||||
def test_pr_merged_moves_card_and_closes_issue(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
client.post("/api/move", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"from_column": "backlog", "to_column": "in_progress",
|
||||
"to_order": "1", "csrf_token": token,
|
||||
})
|
||||
r = _post_webhook(client, _pr_payload())
|
||||
assert r.status_code == 200
|
||||
assert r.json()["handled"] == "pr-merged:#1:closed"
|
||||
assert ("Projets", "demo", 1, "closed") in fake_gitea.state_changes
|
||||
board = models.get_board_state("Projets/demo")
|
||||
assert 1 in [c["issue_number"] for c in board["done"]]
|
||||
events = models.get_events(repo_full_name="Projets/demo")
|
||||
assert any(
|
||||
e["event_type"] == "closed" and e["source"] == "webhook"
|
||||
for e in events
|
||||
)
|
||||
|
||||
|
||||
def test_pr_merged_by_branch_only_and_idempotent(client, fake_gitea):
|
||||
payload = _pr_payload(title="Sans référence", body="", ref="issue-5-focus")
|
||||
r = _post_webhook(client, payload)
|
||||
assert "#5:closed" in r.json()["handled"]
|
||||
r2 = _post_webhook(client, payload) # echo: no double journal entry
|
||||
closed_events = [
|
||||
e for e in models.get_events(repo_full_name="Projets/demo")
|
||||
if e["event_type"] == "closed" and e["issue_number"] == 5
|
||||
]
|
||||
assert len(closed_events) == 1
|
||||
assert r2.json()["handled"] in ("pr-merged:#5:noop", "pr-merged:#5:closed")
|
||||
|
||||
|
||||
def test_pr_not_merged_ignored(client, fake_gitea):
|
||||
r = _post_webhook(client, _pr_payload(merged=False))
|
||||
assert r.json()["handled"] == "ignored"
|
||||
assert models.get_events(repo_full_name="Projets/demo") == []
|
||||
|
||||
|
||||
# ── #6 Standup ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_standup_from_journal(client, fake_gitea):
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
models.log_event("Projets/demo", 3, "closed", to_column="done")
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE board_events SET created_at = ? WHERE issue_number = 3",
|
||||
(yesterday,),
|
||||
)
|
||||
r = client.get("/api/standup")
|
||||
assert r.status_code == 200
|
||||
text = r.json()["text"]
|
||||
assert "Standup" in text
|
||||
assert "1 fermée(s)" in text
|
||||
assert "Ancienne tâche" in text
|
||||
page = client.get("/standup")
|
||||
assert page.status_code == 200
|
||||
assert "Copier le standup" in page.text
|
||||
|
||||
|
||||
# ── #8 Règles ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_rules_validation():
|
||||
rules, errors = rules_mod.parse_rules(
|
||||
'[{"name": "ok", "when": {"column": "review"}, "then": {"notify": true}}]'
|
||||
)
|
||||
assert errors == [] and rules[0]["name"] == "ok"
|
||||
_r, errors = rules_mod.parse_rules("{pas json")
|
||||
assert errors and "JSON invalide" in errors[0]
|
||||
_r, errors = rules_mod.parse_rules(
|
||||
'[{"when": {"column": "review"}, "then": {"close_issue": true}}]'
|
||||
)
|
||||
assert errors and "interdit" in errors[0]
|
||||
_r, errors = rules_mod.parse_rules(
|
||||
'[{"when": {"column": "nope"}, "then": {"notify": true}}]'
|
||||
)
|
||||
assert errors and "colonne inconnue" in errors[0]
|
||||
|
||||
|
||||
def test_matching_cards_conditions():
|
||||
columns = {
|
||||
"review": [
|
||||
{"number": 1, "labels": [{"name": "bloqué"}]},
|
||||
{"number": 2, "labels": []},
|
||||
],
|
||||
"in_progress": [{"number": 3, "labels": []}],
|
||||
}
|
||||
ages = {1: {"days": 5}, 2: {"days": 1}, 3: {"days": 9}}
|
||||
rule = {"name": "r", "when": {"column": "review", "min_age_days": 3},
|
||||
"then": {"notify": True}}
|
||||
assert [i["number"] for i, _ in rules_mod.matching_cards(rule, columns, ages)] == [1]
|
||||
rule2 = {"name": "r2", "when": {"column": "review", "label": "bloqué"},
|
||||
"then": {"notify": True}}
|
||||
assert [i["number"] for i, _ in rules_mod.matching_cards(rule2, columns, ages)] == [1]
|
||||
rule3 = {"name": "r3", "when": {"column": "review", "wip_over": 1},
|
||||
"then": {"notify": True}}
|
||||
assert len(rules_mod.matching_cards(rule3, columns, ages)) == 2
|
||||
rule4 = {"name": "r4", "when": {"column": "in_progress", "wip_over": 3},
|
||||
"then": {"notify": True}}
|
||||
assert rules_mod.matching_cards(rule4, columns, ages) == []
|
||||
|
||||
|
||||
def test_rules_run_end_to_end(client, fake_gitea, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"KANBANTE_RULES",
|
||||
json.dumps([{
|
||||
"name": "Marque le focus",
|
||||
"when": {"column": "in_progress"},
|
||||
"then": {"add_label": "urgent"},
|
||||
}]),
|
||||
)
|
||||
token = csrf_from(client)
|
||||
client.post("/api/move", data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 5,
|
||||
"from_column": "backlog", "to_column": "in_progress",
|
||||
"to_order": "5", "csrf_token": token,
|
||||
})
|
||||
r = client.post("/api/rules/run", data={"csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
actions = r.json()["summary"]["actions"]
|
||||
assert len(actions) == 1
|
||||
assert actions[0]["number"] == 5
|
||||
assert "urgent" in actions[0]["did"][0]
|
||||
detail = client.get("/api/issues/Projets/demo/5")
|
||||
assert "urgent" in [l["name"] for l in detail.json()["issue"]["labels"]]
|
||||
events = models.get_events(repo_full_name="Projets/demo")
|
||||
assert any(e["event_type"] == "rule" for e in events)
|
||||
# Second run: label already present -> no new action.
|
||||
r2 = client.post("/api/rules/run", data={"csrf_token": token})
|
||||
assert r2.json()["summary"]["actions"] == []
|
||||
page = client.get("/rules")
|
||||
assert page.status_code == 200
|
||||
assert "Marque le focus" in page.text
|
||||
|
||||
|
||||
def test_rules_page_unconfigured(client, fake_gitea):
|
||||
page = client.get("/rules")
|
||||
assert page.status_code == 200
|
||||
assert "KANBANTE_RULES" in page.text
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import code_links as cl
|
||||
from app import flow
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── #3 Branche depuis la carte ────────────────────────────────────
|
||||
|
||||
|
||||
def test_slugify_and_branch_name():
|
||||
assert cl.slugify_title("Créer la branche depuis la carte") == \
|
||||
"creer-la-branche-depuis-la-carte"
|
||||
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
|
||||
"fix-l-ete-indien-urgent"
|
||||
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
|
||||
assert len(cl.slugify_title("x" * 100)) == 40
|
||||
assert cl.slugify_title("!!!") == "issue"
|
||||
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
|
||||
# La convention est reconnue par l'appariement (#1/#2).
|
||||
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
|
||||
|
||||
|
||||
def test_create_branch_endpoint(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/issues/Projets/demo/5/branch",
|
||||
data={"csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["branch"] == "issue-5-tache-focus"
|
||||
assert data["from"] == "main"
|
||||
assert data["moved"] is True
|
||||
assert "issue-5-tache-focus" in fake_gitea.branches
|
||||
board = models.get_board_state("Projets/demo")
|
||||
assert 5 in [c["issue_number"] for c in board["in_progress"]]
|
||||
events = models.get_events(repo_full_name="Projets/demo")
|
||||
assert any(
|
||||
e["event_type"] == "moved" and e["source"] == "branch"
|
||||
for e in events
|
||||
)
|
||||
# Deuxième appel : la branche existe déjà, pas de doublon.
|
||||
r2 = client.post("/api/issues/Projets/demo/5/branch",
|
||||
data={"csrf_token": token})
|
||||
assert r2.json()["exists"] is True
|
||||
|
||||
|
||||
def test_create_branch_custom_name_and_validation(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/issues/Projets/demo/1/branch",
|
||||
data={"csrf_token": token, "name": "feature/super-truc"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["branch"] == "feature/super-truc"
|
||||
r = client.post("/api/issues/Projets/demo/1/branch",
|
||||
data={"csrf_token": token, "name": "mauvais nom.."})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_code_endpoint_suggests_branch(client, fake_gitea):
|
||||
r = client.get("/api/issues/Projets/demo/3/code")
|
||||
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
|
||||
|
||||
|
||||
# ── #9 Santé du flux : fonctions pures ────────────────────────────
|
||||
|
||||
|
||||
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
|
||||
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
return {
|
||||
"repo_full_name": "Projets/demo", "issue_number": number,
|
||||
"event_type": kind, "from_column": frm, "to_column": to,
|
||||
"created_at": at,
|
||||
}
|
||||
|
||||
|
||||
def test_column_dwell_reconstruction():
|
||||
events = [
|
||||
_ev(0, "created", to="backlog"),
|
||||
_ev(4, "moved", frm="backlog", to="in_progress"),
|
||||
_ev(10, "closed", frm="in_progress", to="done"),
|
||||
_ev(1, "created", to="backlog", number=2),
|
||||
_ev(3, "moved", frm="backlog", to="review", number=2),
|
||||
]
|
||||
dwell = flow.column_dwell(events)
|
||||
assert dwell["backlog"] == [4.0, 2.0]
|
||||
assert dwell["in_progress"] == [6.0]
|
||||
summary = flow.summarize_dwell(dwell)
|
||||
by_col = {row["column"]: row for row in summary}
|
||||
assert by_col["backlog"]["median_days"] == 3.0
|
||||
assert by_col["backlog"]["count"] == 2
|
||||
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
|
||||
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
|
||||
slowest = flow.slowest_column(summary)
|
||||
assert slowest["column"] == "backlog"
|
||||
events.append(_ev(20, "moved", frm="review", to="done", number=2))
|
||||
events += [
|
||||
_ev(0, "created", to="backlog", number=3),
|
||||
_ev(2, "moved", frm="backlog", to="review", number=3),
|
||||
_ev(19, "moved", frm="review", to="done", number=3),
|
||||
]
|
||||
dwell = flow.column_dwell(events)
|
||||
summary = flow.summarize_dwell(dwell)
|
||||
slowest = flow.slowest_column(summary)
|
||||
assert slowest["column"] == "review"
|
||||
assert slowest["median_days"] == 17.0
|
||||
|
||||
|
||||
def test_last_activity():
|
||||
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
|
||||
latest = flow.last_activity(events)
|
||||
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
|
||||
|
||||
|
||||
# ── #9 Pages santé ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
|
||||
frm="backlog", to="review"):
|
||||
models.log_event("Projets/demo", number, "created", to_column="backlog")
|
||||
models.log_event("Projets/demo", number, "moved",
|
||||
from_column=frm, to_column=to)
|
||||
now = datetime.now()
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE board_events SET created_at = ? "
|
||||
"WHERE issue_number = ? AND event_type = 'created'",
|
||||
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE board_events SET created_at = ? "
|
||||
"WHERE issue_number = ? AND event_type = 'moved'",
|
||||
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
|
||||
)
|
||||
|
||||
|
||||
def test_health_pages_and_bottleneck(client, fake_gitea):
|
||||
_seed_stay(1, 40, 10) # 30 j en backlog
|
||||
_seed_stay(5, 35, 5) # 30 j en backlog aussi
|
||||
page = client.get("/health/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert "backlog" in page.text.lower()
|
||||
index = client.get("/health")
|
||||
assert index.status_code == 200
|
||||
assert "Projets/demo" in index.text
|
||||
|
||||
|
||||
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
|
||||
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
|
||||
# donc zombies au seuil par défaut de 30 j.
|
||||
page = client.get("/health/Projets/demo")
|
||||
assert "Tâche un" in page.text
|
||||
assert 'data-zombie="close"' in page.text
|
||||
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
|
||||
page = client.get("/health/Projets/demo")
|
||||
assert "Aucune carte zombie" in page.text
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Palier 12 (0.15.0): header unifié desktop/mobile (issue #16)."""
|
||||
|
||||
|
||||
def test_shared_header_on_app_pages(client, fake_gitea):
|
||||
for path in ("/", "/focus", "/today", "/briefing", "/forecast",
|
||||
"/rules", "/health", "/standup", "/capture"):
|
||||
page = client.get(path)
|
||||
assert page.status_code == 200, path
|
||||
assert page.text.count('id="nav-more-btn"') == 1, path
|
||||
assert page.text.count('id="nav-burger"') == 1, path
|
||||
assert page.text.count('id="mobile-nav"') == 1, path
|
||||
assert "/static/js/nav.js" in page.text, path
|
||||
# Primaires visibles, secondaires dans le menu « Plus ».
|
||||
assert 'href="/today"' in page.text
|
||||
assert 'href="/health"' in page.text # présent (menu Plus/mobile)
|
||||
assert 'class="nav-pills main-nav"' in page.text
|
||||
|
||||
|
||||
def test_active_states(client, fake_gitea):
|
||||
home = client.get("/")
|
||||
assert 'href="/" class="nav-pill active"' in home.text
|
||||
forecast = client.get("/forecast")
|
||||
# La page active est dans « Plus » : bouton marqué + lien actif.
|
||||
assert "active-soft" in forecast.text
|
||||
assert 'href="/forecast" class="nav-pill active"' in forecast.text
|
||||
today = client.get("/today")
|
||||
assert 'href="/today" class="nav-pill active"' in today.text
|
||||
|
||||
|
||||
def test_board_header_crumb_and_gitea_link(client, fake_gitea):
|
||||
board = client.get("/board/Projets/demo")
|
||||
assert board.status_code == 200
|
||||
assert 'href="/board/Projets/demo"' in board.text # fil d'Ariane
|
||||
assert "Gitea ↗" in board.text
|
||||
assert 'id="nav-burger"' in board.text
|
||||
|
||||
|
||||
def test_wall_has_no_app_header(client, fake_gitea):
|
||||
wall = client.get("/wall")
|
||||
assert wall.status_code == 200
|
||||
assert 'id="nav-burger"' not in wall.text # plein écran TV assumé
|
||||
|
||||
|
||||
def test_nav_js_contract(client, fake_gitea):
|
||||
js = client.get("/static/js/nav.js")
|
||||
assert js.status_code == 200
|
||||
assert "nav-burger" in js.text
|
||||
assert "Escape" in js.text
|
||||
assert "aria-expanded" in js.text
|
||||
@@ -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