Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b41c5ea74 | ||
|
|
370dafd266 | ||
|
|
7eef8bb604 | ||
|
|
0b48026752 | ||
|
|
675ce09083 | ||
|
|
03fa1eb44e | ||
|
|
8e0c7b8f09 | ||
|
|
4c5d1dee23 | ||
|
|
cdabc77ccd | ||
|
|
fbe58bbf16 |
@@ -1,5 +1,64 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 0.11.0 — 2026-10-11
|
||||||
|
|
||||||
### Nouveautés
|
### Nouveautés
|
||||||
|
|||||||
@@ -16,6 +16,13 @@
|
|||||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
- ⏳ **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`)
|
- 🎯 **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
|
- 🖥️ **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`
|
||||||
- 📥 **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
|
- 📥 **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
|
- 🔄 **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
|
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
|
||||||
@@ -120,6 +127,8 @@ sudo systemctl enable --now docker
|
|||||||
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
|
| `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_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_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_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
|
| `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_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}` |
|
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
|
||||||
@@ -170,6 +179,13 @@ kanbante/
|
|||||||
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
|
| 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 | `/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 | `/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/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) |
|
| 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 |
|
| 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 | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ def ntfy_configured(settings: Settings) -> bool:
|
|||||||
return bool(settings.ntfy_url and settings.ntfy_topic)
|
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:
|
def format_briefing_text(data: dict) -> str:
|
||||||
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||||||
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
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:
|
if activity.get("avg_cycle_days") is not None:
|
||||||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
lines.append(f"⏱️ 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)")
|
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||||
for group in data["focus_groups"][:6]:
|
for group in data["focus_groups"][:6]:
|
||||||
lines.append(f" • {group['full_name']} : {group['count']}")
|
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"]:
|
if data["focus_total"] == 0 and not data["aging"]:
|
||||||
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||||||
|
if data.get("standup"):
|
||||||
|
lines.append("\n" + data["standup"])
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
_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 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
+15
-1
@@ -68,6 +68,16 @@ Environment variables:
|
|||||||
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
|
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
|
||||||
"{title}" and "{content}" placeholders
|
"{title}" and "{content}" placeholders
|
||||||
are substituted (JSON-escaped).
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -76,7 +86,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.11.0"
|
APP_VERSION = "0.13.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -142,6 +152,8 @@ class Settings:
|
|||||||
obsigate_mcp_token: str = ""
|
obsigate_mcp_token: str = ""
|
||||||
obsigate_mcp_tool: str = ""
|
obsigate_mcp_tool: str = ""
|
||||||
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||||
|
briefing_include_standup: bool = False
|
||||||
|
rules_raw: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -184,4 +196,6 @@ def get_settings() -> Settings:
|
|||||||
"KANBANTE_OBSIGATE_MCP_ARGS",
|
"KANBANTE_OBSIGATE_MCP_ARGS",
|
||||||
'{"title": "{title}", "content": "{content}"}',
|
'{"title": "{title}", "content": "{content}"}',
|
||||||
),
|
),
|
||||||
|
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
|
||||||
|
rules_raw=os.getenv("KANBANTE_RULES", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
|||||||
{
|
{
|
||||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||||
"capabilities": {},
|
"capabilities": {},
|
||||||
"clientInfo": {"name": "kanbante", "version": "0.11.0"},
|
"clientInfo": {"name": "kanbante", "version": "0.13.0"},
|
||||||
},
|
},
|
||||||
req_id=1,
|
req_id=1,
|
||||||
)
|
)
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
"""Forecasts (roadmap #7): honest ETAs from the event journal.
|
||||||
|
|
||||||
|
Throughput = issues closed per week, counted from board_events.
|
||||||
|
Projections are deliberately given as a RANGE (best/worst weekly
|
||||||
|
rate over the last 4 complete weeks) rather than a fake-precise
|
||||||
|
date, and are withheld entirely when the journal has no closures
|
||||||
|
to learn from — "not enough history" is an answer, too.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
MONTHS_FR = [
|
||||||
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
||||||
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
||||||
|
]
|
||||||
|
MONTHS_FR_SHORT = [
|
||||||
|
"janv.", "févr.", "mars", "avr.", "mai", "juin",
|
||||||
|
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def french_date(d: date, short: bool = False) -> str:
|
||||||
|
months = MONTHS_FR_SHORT if short else MONTHS_FR
|
||||||
|
return f"{d.day} {months[d.month - 1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _event_date(event: dict) -> date | None:
|
||||||
|
raw = str(event.get("created_at") or "")
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def weekly_closures(events: list[dict], weeks: int = 8,
|
||||||
|
today: date | None = None) -> list[dict]:
|
||||||
|
"""Closure counts per ISO week, oldest first, current week last."""
|
||||||
|
today = today or date.today()
|
||||||
|
current_monday = today - timedelta(days=today.weekday())
|
||||||
|
buckets: list[dict] = []
|
||||||
|
for i in range(weeks - 1, -1, -1):
|
||||||
|
start = current_monday - timedelta(weeks=i)
|
||||||
|
buckets.append({"start": start, "count": 0})
|
||||||
|
by_start = {b["start"]: b for b in buckets}
|
||||||
|
for event in events:
|
||||||
|
if event.get("event_type") != "closed":
|
||||||
|
continue
|
||||||
|
day = _event_date(event)
|
||||||
|
if day is None:
|
||||||
|
continue
|
||||||
|
monday = day - timedelta(days=day.weekday())
|
||||||
|
if monday in by_start:
|
||||||
|
by_start[monday]["count"] += 1
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
|
||||||
|
"""avg/min/max closures per week over the last COMPLETE weeks.
|
||||||
|
|
||||||
|
The current (partial) week is excluded: on Monday morning it
|
||||||
|
would crush the average for no reason. Returns None when the
|
||||||
|
complete weeks contain no closure at all.
|
||||||
|
"""
|
||||||
|
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
|
||||||
|
if not complete:
|
||||||
|
return None
|
||||||
|
counts = [b["count"] for b in complete]
|
||||||
|
if sum(counts) == 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"avg": sum(counts) / len(counts),
|
||||||
|
"min": min(counts),
|
||||||
|
"max": max(counts),
|
||||||
|
"weeks": len(counts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate(remaining: int, weekly: list[dict],
|
||||||
|
today: date | None = None) -> dict | None:
|
||||||
|
"""Projected completion for `remaining` items at the real pace.
|
||||||
|
|
||||||
|
Returns {"done": True} when nothing remains, None when there is
|
||||||
|
no throughput to project from, else dates: central (avg rate),
|
||||||
|
earliest (best week), latest (worst nonzero week, if any).
|
||||||
|
"""
|
||||||
|
today = today or date.today()
|
||||||
|
if remaining <= 0:
|
||||||
|
return {"done": True}
|
||||||
|
stats = throughput_stats(weekly)
|
||||||
|
if stats is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def at_rate(rate: float) -> date:
|
||||||
|
days = remaining / rate * 7
|
||||||
|
return today + timedelta(days=max(1, round(days)))
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"done": False,
|
||||||
|
"avg_per_week": round(stats["avg"], 1),
|
||||||
|
"central": at_rate(stats["avg"]),
|
||||||
|
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
|
||||||
|
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
|
||||||
|
"""One-line French label for banners and cards (or None)."""
|
||||||
|
if est is None:
|
||||||
|
return None
|
||||||
|
if est.get("done"):
|
||||||
|
return f"✅ {done_text}"
|
||||||
|
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
|
||||||
|
if est.get("earliest") and est.get("latest"):
|
||||||
|
label += (
|
||||||
|
f" (fourchette {french_date(est['earliest'], short=True)}"
|
||||||
|
f" – {french_date(est['latest'], short=True)})"
|
||||||
|
)
|
||||||
|
return label
|
||||||
@@ -341,6 +341,25 @@ class GiteaClient:
|
|||||||
self._cache_clear()
|
self._cache_clear()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
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(
|
async def set_issue_assignees(
|
||||||
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
+753
-3
@@ -35,23 +35,35 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
|
from app import code_links as code_links_service
|
||||||
from app import digest as digest_service
|
from app import digest as digest_service
|
||||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
from app import forecast as forecast_service
|
||||||
|
from app import rules as rules_service
|
||||||
|
from app import sprint as sprint_service
|
||||||
|
from app.briefing import (
|
||||||
|
format_briefing_text,
|
||||||
|
format_standup_text,
|
||||||
|
ntfy_configured,
|
||||||
|
send_ntfy,
|
||||||
|
)
|
||||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||||
from app.models import (
|
from app.models import (
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
|
add_pick,
|
||||||
column_entry_times,
|
column_entry_times,
|
||||||
get_board_state,
|
get_board_state,
|
||||||
get_events,
|
get_events,
|
||||||
get_kv,
|
get_kv,
|
||||||
get_metrics,
|
get_metrics,
|
||||||
get_notes,
|
get_notes,
|
||||||
|
get_picks,
|
||||||
get_stats,
|
get_stats,
|
||||||
init_db,
|
init_db,
|
||||||
log_event,
|
log_event,
|
||||||
move_issue,
|
move_issue,
|
||||||
ping_db,
|
ping_db,
|
||||||
reconcile_board_state,
|
reconcile_board_state,
|
||||||
|
remove_pick,
|
||||||
set_column_order,
|
set_column_order,
|
||||||
set_kv,
|
set_kv,
|
||||||
set_notes,
|
set_notes,
|
||||||
@@ -143,6 +155,7 @@ async def lifespan(app: FastAPI):
|
|||||||
)
|
)
|
||||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
digest_task = asyncio.create_task(_digest_scheduler())
|
digest_task = asyncio.create_task(_digest_scheduler())
|
||||||
|
rules_task = asyncio.create_task(_rules_scheduler())
|
||||||
capture_task = None
|
capture_task = None
|
||||||
if settings.capture_ntfy_topic and settings.ntfy_url:
|
if settings.capture_ntfy_topic and settings.ntfy_url:
|
||||||
async def _capture_create(title: str, body: str) -> None:
|
async def _capture_create(title: str, body: str) -> None:
|
||||||
@@ -166,6 +179,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
scheduler.cancel()
|
scheduler.cancel()
|
||||||
digest_task.cancel()
|
digest_task.cancel()
|
||||||
|
rules_task.cancel()
|
||||||
if capture_task is not None:
|
if capture_task is not None:
|
||||||
capture_task.cancel()
|
capture_task.cancel()
|
||||||
await gitea.aclose()
|
await gitea.aclose()
|
||||||
@@ -457,6 +471,10 @@ async def dashboard(request: Request):
|
|||||||
"""Main dashboard — all projects at a glance."""
|
"""Main dashboard — all projects at a glance."""
|
||||||
repos = await gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
active, inactive, archived = classify_repos(repos)
|
active, inactive, archived = classify_repos(repos)
|
||||||
|
for repo_info in active:
|
||||||
|
repo_info["forecast_label"] = _repo_forecast_label(
|
||||||
|
repo_info["full_name"], repo_info.get("open_issues_count") or 0
|
||||||
|
)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -494,6 +512,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
milestones: list[dict] = []
|
milestones: list[dict] = []
|
||||||
selected_milestone = None
|
selected_milestone = None
|
||||||
milestone_progress = None
|
milestone_progress = None
|
||||||
|
milestone_forecast = None
|
||||||
display_issues = open_issues
|
display_issues = open_issues
|
||||||
try:
|
try:
|
||||||
milestones = await gitea.get_milestones(owner, repo)
|
milestones = await gitea.get_milestones(owner, repo)
|
||||||
@@ -527,9 +546,26 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
"due_on": (selected_milestone or {}).get("due_on"),
|
"due_on": (selected_milestone or {}).get("due_on"),
|
||||||
"title": (selected_milestone or {}).get("title", milestone),
|
"title": (selected_milestone or {}).get("title", milestone),
|
||||||
}
|
}
|
||||||
|
# Forecast (issue #7): ETA of the remaining milestone work
|
||||||
|
# at this repo's real closure pace.
|
||||||
|
remaining = total - closed_count
|
||||||
|
weekly = forecast_service.weekly_closures(
|
||||||
|
get_events(repo_full_name=full_name, limit=2000)
|
||||||
|
)
|
||||||
|
est = forecast_service.estimate(remaining, weekly)
|
||||||
|
if est is None:
|
||||||
|
milestone_forecast = "pas assez d'historique pour prévoir"
|
||||||
|
else:
|
||||||
|
milestone_forecast = forecast_service.eta_label(est)
|
||||||
|
due_raw = milestone_progress["due_on"]
|
||||||
|
if due_raw and not est.get("done"):
|
||||||
|
due_date = _parse_datetime(str(due_raw))
|
||||||
|
if due_date and est["central"] > due_date.date():
|
||||||
|
milestone_forecast += " — ⚠️ après l'échéance"
|
||||||
|
|
||||||
board_state = get_board_state(full_name)
|
board_state = get_board_state(full_name)
|
||||||
columns = _merge_issues_with_board(display_issues, board_state)
|
columns = _merge_issues_with_board(display_issues, board_state)
|
||||||
|
await _annotate_code(owner, repo, columns)
|
||||||
|
|
||||||
labels: dict[str, dict] = {}
|
labels: dict[str, dict] = {}
|
||||||
for issue in open_issues:
|
for issue in open_issues:
|
||||||
@@ -561,6 +597,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
|||||||
milestones=milestones,
|
milestones=milestones,
|
||||||
selected_milestone=selected_milestone,
|
selected_milestone=selected_milestone,
|
||||||
milestone_progress=milestone_progress,
|
milestone_progress=milestone_progress,
|
||||||
|
milestone_forecast=milestone_forecast,
|
||||||
milestone_param=milestone,
|
milestone_param=milestone,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -606,6 +643,7 @@ async def api_board(owner: str, repo: str, milestone: str = ""):
|
|||||||
columns, display_issues, board_state = _board_columns_for(
|
columns, display_issues, board_state = _board_columns_for(
|
||||||
owner, repo, all_items, milestone
|
owner, repo, all_items, milestone
|
||||||
)
|
)
|
||||||
|
await _annotate_code(owner, repo, columns)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
ages = _card_ages(full_name, display_issues, board_state)
|
ages = _card_ages(full_name, display_issues, board_state)
|
||||||
card_template = _tpl_env.get_template("_card.html")
|
card_template = _tpl_env.get_template("_card.html")
|
||||||
@@ -788,6 +826,520 @@ async def triage_session(request: Request, owner: str, repo: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _today_str(offset_days: int = 0) -> str:
|
||||||
|
return (datetime.now() + timedelta(days=offset_days)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
async def _enrich_pick(pick: dict) -> dict | None:
|
||||||
|
"""A today pick + its live issue data and current column."""
|
||||||
|
owner, _, name = pick["repo_full_name"].partition("/")
|
||||||
|
try:
|
||||||
|
issue = await gitea.get_issue(owner, name, pick["issue_number"])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if issue.get("state") == "closed":
|
||||||
|
column = "done"
|
||||||
|
else:
|
||||||
|
placement = {
|
||||||
|
card["issue_number"]: col
|
||||||
|
for col, cards in get_board_state(pick["repo_full_name"]).items()
|
||||||
|
for card in cards
|
||||||
|
}
|
||||||
|
column = placement.get(pick["issue_number"], "backlog")
|
||||||
|
return {
|
||||||
|
"repo": pick["repo_full_name"],
|
||||||
|
"number": pick["issue_number"],
|
||||||
|
"title": issue.get("title", ""),
|
||||||
|
"state": issue.get("state", "open"),
|
||||||
|
"column": column,
|
||||||
|
"column_label": COLUMN_LABELS[column],
|
||||||
|
"html_url": issue.get("html_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/today/pin")
|
||||||
|
async def api_today_pin(
|
||||||
|
request: Request,
|
||||||
|
repo_full_name: str = Form(...),
|
||||||
|
issue_number: int = Form(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Pin a card in today's focus (max 3, issue #5)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
if _split_full_name(repo_full_name) is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
|
||||||
|
)
|
||||||
|
today = _today_str()
|
||||||
|
picks = get_picks(today)
|
||||||
|
if any(
|
||||||
|
p["repo_full_name"] == repo_full_name and p["issue_number"] == issue_number
|
||||||
|
for p in picks
|
||||||
|
):
|
||||||
|
return {"ok": True, "already": True, "count": len(picks)}
|
||||||
|
if len(picks) >= 3:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Focus du jour complet (3 cartes max) — "
|
||||||
|
"retire d'abord une carte sur /today.",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
add_pick(today, repo_full_name, issue_number)
|
||||||
|
return {"ok": True, "count": len(picks) + 1}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/today/unpin")
|
||||||
|
async def api_today_unpin(
|
||||||
|
request: Request,
|
||||||
|
repo_full_name: str = Form(...),
|
||||||
|
issue_number: int = Form(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Remove a card from today's focus."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
remove_pick(_today_str(), repo_full_name, issue_number)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/today", response_class=HTMLResponse)
|
||||||
|
async def today_page(request: Request):
|
||||||
|
"""Focus of the day (issue #5): up to 3 pinned cards, calmly."""
|
||||||
|
today = _today_str()
|
||||||
|
picks = []
|
||||||
|
for pick in get_picks(today):
|
||||||
|
enriched = await _enrich_pick(pick)
|
||||||
|
if enriched:
|
||||||
|
picks.append(enriched)
|
||||||
|
picked_keys = {(p["repo"], p["number"]) for p in picks}
|
||||||
|
|
||||||
|
carry_over = []
|
||||||
|
for pick in get_picks(_today_str(-1)):
|
||||||
|
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
|
||||||
|
continue
|
||||||
|
enriched = await _enrich_pick(pick)
|
||||||
|
if enriched and enriched["state"] != "closed":
|
||||||
|
carry_over.append(enriched)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
if len(picks) < 3:
|
||||||
|
for group in await _collect_focus_groups():
|
||||||
|
for card in group["cards"]:
|
||||||
|
key = (group["full_name"], card["issue"]["number"])
|
||||||
|
if key in picked_keys:
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
{
|
||||||
|
"repo": group["full_name"],
|
||||||
|
"number": card["issue"]["number"],
|
||||||
|
"title": card["issue"].get("title", ""),
|
||||||
|
"column_label": COLUMN_LABELS[card["column"]],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
candidates = candidates[:8]
|
||||||
|
|
||||||
|
recap = []
|
||||||
|
picked_numbers = {(p["repo"], p["number"]) for p in picks}
|
||||||
|
for event in get_events(limit=300):
|
||||||
|
if not str(event.get("created_at") or "").startswith(today):
|
||||||
|
continue
|
||||||
|
if (event["repo_full_name"], event["issue_number"]) in picked_numbers:
|
||||||
|
recap.append(event)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"today.html",
|
||||||
|
date_label=datetime.now().strftime("%A %d %B %Y"),
|
||||||
|
picks=picks,
|
||||||
|
carry_over=carry_over,
|
||||||
|
candidates=candidates,
|
||||||
|
recap=recap,
|
||||||
|
column_labels=COLUMN_LABELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sprint_markdown(owner: str, repo: str, milestone: str):
|
||||||
|
"""Assemble the sprint story; (title, markdown) or None."""
|
||||||
|
full_name = f"{owner}/{repo}"
|
||||||
|
try:
|
||||||
|
milestones = await gitea.get_milestones(owner, repo)
|
||||||
|
except Exception:
|
||||||
|
milestones = []
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
m for m in milestones
|
||||||
|
if str(m.get("id")) == milestone or m.get("title") == milestone
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if selected is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _in_ms(issue: dict) -> bool:
|
||||||
|
ms = issue.get("milestone") or {}
|
||||||
|
return bool(ms) and (
|
||||||
|
str(ms.get("id")) == str(selected.get("id"))
|
||||||
|
or ms.get("title") == selected.get("title")
|
||||||
|
)
|
||||||
|
|
||||||
|
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||||
|
scoped = [i for i in all_items if _is_issue(i) and _in_ms(i)]
|
||||||
|
numbers = {i["number"] for i in scoped}
|
||||||
|
|
||||||
|
events = [
|
||||||
|
e for e in get_events(repo_full_name=full_name, limit=2000)
|
||||||
|
if e["issue_number"] in numbers
|
||||||
|
]
|
||||||
|
first_in_progress: dict[int, object] = {}
|
||||||
|
closed_at: dict[int, object] = {}
|
||||||
|
counts = {"moved": 0, "commented": 0, "reopened": 0, "created": 0}
|
||||||
|
event_dates = []
|
||||||
|
for event in events:
|
||||||
|
at = _parse_datetime(str(event.get("created_at") or ""))
|
||||||
|
if at is not None:
|
||||||
|
event_dates.append(at)
|
||||||
|
if event["event_type"] in counts:
|
||||||
|
counts[event["event_type"]] += 1
|
||||||
|
if (
|
||||||
|
event["event_type"] == "moved"
|
||||||
|
and event.get("to_column") == "in_progress"
|
||||||
|
):
|
||||||
|
first_in_progress.setdefault(event["issue_number"], at)
|
||||||
|
if event["event_type"] == "closed":
|
||||||
|
closed_at[event["issue_number"]] = at
|
||||||
|
|
||||||
|
columns, _display, _state = _board_columns_for(owner, repo, all_items)
|
||||||
|
placement = {
|
||||||
|
card["number"]: col for col, cards in columns.items() for card in cards
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered, carried, cycles = [], [], []
|
||||||
|
for issue in scoped:
|
||||||
|
if issue.get("state") == "closed":
|
||||||
|
cycle = None
|
||||||
|
start = first_in_progress.get(issue["number"])
|
||||||
|
end = closed_at.get(issue["number"])
|
||||||
|
if start is None:
|
||||||
|
start = _parse_datetime(str(issue.get("created_at") or ""))
|
||||||
|
if end is None:
|
||||||
|
end = _parse_datetime(str(issue.get("closed_at") or ""))
|
||||||
|
if start is not None and end is not None:
|
||||||
|
cycle = round((end - start).total_seconds() / 86400, 1)
|
||||||
|
if cycle < 0:
|
||||||
|
cycle = None
|
||||||
|
else:
|
||||||
|
cycles.append(cycle)
|
||||||
|
delivered.append(
|
||||||
|
{
|
||||||
|
"number": issue["number"],
|
||||||
|
"title": issue.get("title", ""),
|
||||||
|
"html_url": issue.get("html_url", ""),
|
||||||
|
"cycle_days": cycle,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
col = placement.get(issue["number"], "backlog")
|
||||||
|
carried.append(
|
||||||
|
{
|
||||||
|
"number": issue["number"],
|
||||||
|
"title": issue.get("title", ""),
|
||||||
|
"column_label": COLUMN_LABELS[col],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
delivered.sort(key=lambda d: d["number"])
|
||||||
|
carried.sort(key=lambda d: d["number"])
|
||||||
|
|
||||||
|
if event_dates:
|
||||||
|
period = (
|
||||||
|
f"du {min(event_dates).strftime('%Y-%m-%d')} "
|
||||||
|
f"au {max(event_dates).strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
period = "aucun événement journalisé pour ce milestone"
|
||||||
|
avg_cycle = round(sum(cycles) / len(cycles), 1) if cycles else None
|
||||||
|
markdown = sprint_service.format_sprint_markdown(
|
||||||
|
full_name,
|
||||||
|
selected.get("title", milestone),
|
||||||
|
selected.get("due_on"),
|
||||||
|
period,
|
||||||
|
delivered,
|
||||||
|
carried,
|
||||||
|
counts,
|
||||||
|
avg_cycle,
|
||||||
|
)
|
||||||
|
return selected.get("title", milestone), markdown
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/sprint/{owner}/{repo}")
|
||||||
|
async def api_sprint(owner: str, repo: str, milestone: str = ""):
|
||||||
|
"""The sprint story as JSON markdown (issue #4)."""
|
||||||
|
result = await _sprint_markdown(owner, repo, milestone)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Milestone introuvable"}, status_code=404
|
||||||
|
)
|
||||||
|
title, markdown = result
|
||||||
|
return {"ok": True, "milestone": title, "markdown": markdown}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sprint/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
|
async def sprint_page(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||||
|
"""Sprint story page: rendered narrative + copy/download."""
|
||||||
|
full_name = f"{owner}/{repo}"
|
||||||
|
result = await _sprint_markdown(owner, repo, milestone) if milestone else None
|
||||||
|
milestones = []
|
||||||
|
if result is None:
|
||||||
|
try:
|
||||||
|
milestones = await gitea.get_milestones(owner, repo)
|
||||||
|
except Exception:
|
||||||
|
milestones = []
|
||||||
|
title, markdown = result if result else ("", "")
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"sprint.html",
|
||||||
|
full_name=full_name,
|
||||||
|
milestone_title=title,
|
||||||
|
markdown=markdown,
|
||||||
|
milestones=milestones,
|
||||||
|
milestone_param=milestone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_rule_actions(
|
||||||
|
summary: dict, rule: dict, full_name: str, owner: str, repo: str,
|
||||||
|
issue: dict, column: str, columns: dict, ages: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Execute one rule's actions on one card (issue #8)."""
|
||||||
|
then = rule["then"]
|
||||||
|
number = issue["number"]
|
||||||
|
acted: list[str] = []
|
||||||
|
if then.get("add_label"):
|
||||||
|
label = then["add_label"]
|
||||||
|
present = [
|
||||||
|
(lab or {}).get("name") for lab in issue.get("labels") or []
|
||||||
|
]
|
||||||
|
if label not in present:
|
||||||
|
lab = await gitea.ensure_label(owner, repo, label, "fb8500")
|
||||||
|
await gitea.add_issue_label(owner, repo, number, lab["id"])
|
||||||
|
acted.append(f"étiquette « {label} » ajoutée")
|
||||||
|
if then.get("move_top"):
|
||||||
|
order = [c["number"] for c in columns.get(column, [])]
|
||||||
|
if order and order[0] != number:
|
||||||
|
set_column_order(
|
||||||
|
full_name, column,
|
||||||
|
[number] + [n for n in order if n != number],
|
||||||
|
)
|
||||||
|
acted.append("remontée en tête de colonne")
|
||||||
|
if then.get("notify"):
|
||||||
|
guard = (
|
||||||
|
f"rule_notified:{rule['name']}:{full_name}:{number}:{_today_str()}"
|
||||||
|
)
|
||||||
|
if not get_kv(guard):
|
||||||
|
age = (ages.get(number) or {}).get("days")
|
||||||
|
age_txt = f" depuis {age} j" if age is not None else ""
|
||||||
|
text = (
|
||||||
|
f"⚙️ Règle « {rule['name']} »\n"
|
||||||
|
f"{full_name} #{number} « {issue.get('title', '')} » — "
|
||||||
|
f"{COLUMN_LABELS[column]}{age_txt}"
|
||||||
|
)
|
||||||
|
if await send_ntfy(get_settings(), text):
|
||||||
|
set_kv(guard, "1")
|
||||||
|
acted.append("notification ntfy envoyée")
|
||||||
|
if acted:
|
||||||
|
log_event(
|
||||||
|
full_name, number, "rule",
|
||||||
|
from_column=column, to_column=column, source="rule",
|
||||||
|
)
|
||||||
|
summary["actions"].append(
|
||||||
|
{
|
||||||
|
"rule": rule["name"],
|
||||||
|
"repo": full_name,
|
||||||
|
"number": number,
|
||||||
|
"column": column,
|
||||||
|
"did": acted,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_rules_once() -> dict:
|
||||||
|
"""Evaluate every configured rule once (scheduler + manual run)."""
|
||||||
|
settings = get_settings()
|
||||||
|
rules, errors = rules_service.parse_rules(settings.rules_raw)
|
||||||
|
summary: dict = {
|
||||||
|
"at": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"rules": len(rules),
|
||||||
|
"errors": errors,
|
||||||
|
"actions": [],
|
||||||
|
}
|
||||||
|
if rules:
|
||||||
|
repos = await gitea.search_repos()
|
||||||
|
active, _inactive, _archived = classify_repos(repos)
|
||||||
|
for repo_info in active[:50]:
|
||||||
|
full_name = repo_info["full_name"]
|
||||||
|
owner, _, name = full_name.partition("/")
|
||||||
|
try:
|
||||||
|
all_items = await gitea.get_repo_issues(
|
||||||
|
owner, name, state="all"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
columns, display_issues, board_state = _board_columns_for(
|
||||||
|
owner, name, all_items
|
||||||
|
)
|
||||||
|
ages = _card_ages(full_name, display_issues, board_state)
|
||||||
|
for rule in rules:
|
||||||
|
matches = rules_service.matching_cards(rule, columns, ages)
|
||||||
|
for issue, column in matches:
|
||||||
|
if len(summary["actions"]) >= 50:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await _apply_rule_actions(
|
||||||
|
summary, rule, full_name, owner, name,
|
||||||
|
issue, column, columns, ages,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Rule %s failed on %s#%s: %s",
|
||||||
|
rule["name"], full_name, issue["number"], exc,
|
||||||
|
)
|
||||||
|
set_kv("rules_last_run", json.dumps(summary, ensure_ascii=False))
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
async def _rules_scheduler() -> None:
|
||||||
|
"""Evaluate the automation rules every 15 minutes (issue #8)."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if get_settings().rules_raw.strip():
|
||||||
|
await _run_rules_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Rules evaluation failed")
|
||||||
|
await asyncio.sleep(900)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rules", response_class=HTMLResponse)
|
||||||
|
async def rules_page(request: Request):
|
||||||
|
"""Automation rules: configuration status + last evaluation."""
|
||||||
|
settings = get_settings()
|
||||||
|
rules, errors = rules_service.parse_rules(settings.rules_raw)
|
||||||
|
last_run = None
|
||||||
|
raw = get_kv("rules_last_run")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
last_run = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
last_run = None
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules.html",
|
||||||
|
rules=rules,
|
||||||
|
errors=errors,
|
||||||
|
configured=bool(settings.rules_raw.strip()),
|
||||||
|
last_run=last_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/rules/run")
|
||||||
|
async def api_rules_run(request: Request, csrf_token: str = Form("")):
|
||||||
|
"""Evaluate the rules right now (manual trigger)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
summary = await _run_rules_once()
|
||||||
|
return {"ok": True, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/standup")
|
||||||
|
async def api_standup():
|
||||||
|
"""The automatic standup as JSON text (issue #6)."""
|
||||||
|
data = await _briefing_data()
|
||||||
|
text = format_standup_text(await _standup_data(data))
|
||||||
|
return {"ok": True, "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/standup", response_class=HTMLResponse)
|
||||||
|
async def standup_page(request: Request):
|
||||||
|
"""Standup page: yesterday / today / blockers, copy in one click."""
|
||||||
|
data = await _briefing_data()
|
||||||
|
text = format_standup_text(await _standup_data(data))
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"standup.html",
|
||||||
|
text=text,
|
||||||
|
include_in_briefing=get_settings().briefing_include_standup,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/forecast", response_class=HTMLResponse)
|
||||||
|
async def forecast_page(request: Request):
|
||||||
|
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||||
|
repos = await gitea.search_repos()
|
||||||
|
active, _inactive, _archived = classify_repos(repos)
|
||||||
|
projects = []
|
||||||
|
for repo_info in active:
|
||||||
|
full = repo_info["full_name"]
|
||||||
|
owner, _, name = full.partition("/")
|
||||||
|
weekly = forecast_service.weekly_closures(
|
||||||
|
get_events(repo_full_name=full, limit=2000)
|
||||||
|
)
|
||||||
|
max_count = max((b["count"] for b in weekly), default=0)
|
||||||
|
bars = [
|
||||||
|
{
|
||||||
|
"label": b["start"].strftime("S%V"),
|
||||||
|
"count": b["count"],
|
||||||
|
"h": round(100 * b["count"] / max_count) if max_count else 0,
|
||||||
|
}
|
||||||
|
for b in weekly
|
||||||
|
]
|
||||||
|
remaining = repo_info.get("open_issues_count") or 0
|
||||||
|
est = forecast_service.estimate(remaining, weekly)
|
||||||
|
milestones = []
|
||||||
|
try:
|
||||||
|
ms_list = await gitea.get_milestones(owner, name)
|
||||||
|
except Exception:
|
||||||
|
ms_list = []
|
||||||
|
for ms in ms_list:
|
||||||
|
if ms.get("state") not in (None, "open"):
|
||||||
|
continue
|
||||||
|
rem = ms.get("open_issues") or 0
|
||||||
|
if rem == 0 and not (ms.get("closed_issues") or 0):
|
||||||
|
continue
|
||||||
|
m_est = forecast_service.estimate(rem, weekly)
|
||||||
|
verdict = None
|
||||||
|
due_raw = ms.get("due_on")
|
||||||
|
if m_est and not m_est.get("done") and due_raw:
|
||||||
|
due = _parse_datetime(str(due_raw))
|
||||||
|
if due is not None:
|
||||||
|
verdict = (
|
||||||
|
"⚠️ en retard à ce rythme"
|
||||||
|
if m_est["central"] > due.date()
|
||||||
|
else "✅ dans les temps à ce rythme"
|
||||||
|
)
|
||||||
|
milestones.append(
|
||||||
|
{
|
||||||
|
"title": ms.get("title", ""),
|
||||||
|
"due_on": (due_raw or "")[:10],
|
||||||
|
"remaining": rem,
|
||||||
|
"eta": forecast_service.eta_label(m_est),
|
||||||
|
"verdict": verdict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
projects.append(
|
||||||
|
{
|
||||||
|
"full_name": full,
|
||||||
|
"bars": bars,
|
||||||
|
"stats": forecast_service.throughput_stats(weekly),
|
||||||
|
"remaining": remaining,
|
||||||
|
"eta_label": forecast_service.eta_label(est),
|
||||||
|
"milestones": milestones,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return render(request, "forecast.html", projects=projects)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/focus", response_class=HTMLResponse)
|
@app.get("/focus", response_class=HTMLResponse)
|
||||||
async def focus(request: Request):
|
async def focus(request: Request):
|
||||||
"""Cross-project view: everything in progress or in review."""
|
"""Cross-project view: everything in progress or in review."""
|
||||||
@@ -1033,8 +1585,27 @@ async def _briefing_data() -> dict:
|
|||||||
|
|
||||||
metrics_24h = get_metrics(window_days=1)
|
metrics_24h = get_metrics(window_days=1)
|
||||||
metrics_30d = get_metrics(window_days=30)
|
metrics_30d = get_metrics(window_days=30)
|
||||||
return {
|
|
||||||
|
# Focus of the day (issue #5): today's pins + yesterday's
|
||||||
|
# unfinished picks, proposed again in the morning briefing.
|
||||||
|
today_picks: list[dict] = []
|
||||||
|
for pick in get_picks(_today_str()):
|
||||||
|
enriched = await _enrich_pick(pick)
|
||||||
|
if enriched:
|
||||||
|
today_picks.append(enriched)
|
||||||
|
picked_keys = {(p["repo"], p["number"]) for p in today_picks}
|
||||||
|
carry_over: list[dict] = []
|
||||||
|
for pick in get_picks(_today_str(-1)):
|
||||||
|
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
|
||||||
|
continue
|
||||||
|
enriched = await _enrich_pick(pick)
|
||||||
|
if enriched and enriched["state"] != "closed":
|
||||||
|
carry_over.append(enriched)
|
||||||
|
|
||||||
|
data = {
|
||||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||||
|
"today_picks": today_picks,
|
||||||
|
"carry_over": carry_over,
|
||||||
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
||||||
"focus_groups": [
|
"focus_groups": [
|
||||||
{
|
{
|
||||||
@@ -1051,6 +1622,66 @@ async def _briefing_data() -> dict:
|
|||||||
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if get_settings().briefing_include_standup:
|
||||||
|
data["standup"] = format_standup_text(await _standup_data(data))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def _standup_data(briefing_data: dict) -> dict:
|
||||||
|
"""Yesterday / today / blockers for the automatic standup (#6)."""
|
||||||
|
yesterday = datetime.now() - timedelta(days=1)
|
||||||
|
y_str = yesterday.strftime("%Y-%m-%d")
|
||||||
|
events = [
|
||||||
|
e for e in get_events(limit=1000)
|
||||||
|
if str(e.get("created_at") or "").startswith(y_str)
|
||||||
|
]
|
||||||
|
closed_keys: list[tuple[str, int]] = []
|
||||||
|
seen: set[tuple[str, int]] = set()
|
||||||
|
moved = 0
|
||||||
|
for event in events:
|
||||||
|
if event["event_type"] == "closed":
|
||||||
|
key = (event["repo_full_name"], event["issue_number"])
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
closed_keys.append(key)
|
||||||
|
elif event["event_type"] == "moved":
|
||||||
|
moved += 1
|
||||||
|
titles: dict[tuple[str, int], str] = {}
|
||||||
|
for repo_name in {repo for repo, _ in closed_keys}:
|
||||||
|
owner, _, name = repo_name.partition("/")
|
||||||
|
try:
|
||||||
|
items = await gitea.get_repo_issues(owner, name, state="all")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for item in items:
|
||||||
|
if _is_issue(item):
|
||||||
|
titles[(repo_name, item["number"])] = item.get("title", "")
|
||||||
|
blockers = [
|
||||||
|
card for card in briefing_data.get("aging", [])
|
||||||
|
if "Révision" in card.get("column_label", "")
|
||||||
|
or "QA" in card.get("column_label", "")
|
||||||
|
]
|
||||||
|
focus_titles = [
|
||||||
|
f"{g['full_name']} — {title}"
|
||||||
|
for g in briefing_data.get("focus_groups", [])
|
||||||
|
for title in g.get("titles", [])[:2]
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"today_label": datetime.now().strftime("%A %d %B %Y"),
|
||||||
|
"yesterday_label": yesterday.strftime("%A %d %B"),
|
||||||
|
"closed_yesterday": [
|
||||||
|
{
|
||||||
|
"repo": repo,
|
||||||
|
"number": number,
|
||||||
|
"title": titles.get((repo, number), ""),
|
||||||
|
}
|
||||||
|
for repo, number in closed_keys
|
||||||
|
],
|
||||||
|
"moved_yesterday": moved,
|
||||||
|
"picks": briefing_data.get("today_picks", []),
|
||||||
|
"focus_titles": focus_titles,
|
||||||
|
"blockers": blockers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/briefing", response_class=HTMLResponse)
|
@app.get("/briefing", response_class=HTMLResponse)
|
||||||
@@ -1133,6 +1764,60 @@ def _apply_external_transition(
|
|||||||
return event_type
|
return event_type
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_pr_merged(full_name: str, pr: dict) -> list[str]:
|
||||||
|
"""A merged PR sends its linked cards to done (issue #2).
|
||||||
|
|
||||||
|
Links come from #n references in the PR title/body and from the
|
||||||
|
head branch name (issue-n-*). The issue is closed in Gitea when
|
||||||
|
it is not already (a plain reference does not auto-close), the
|
||||||
|
card is moved idempotently, and the journal records the webhook
|
||||||
|
as the source.
|
||||||
|
"""
|
||||||
|
refs = code_links_service.extract_issue_refs(
|
||||||
|
pr.get("title")
|
||||||
|
) | code_links_service.extract_issue_refs(pr.get("body"))
|
||||||
|
branch_number = code_links_service.branch_issue_number(
|
||||||
|
(pr.get("head") or {}).get("ref")
|
||||||
|
)
|
||||||
|
if branch_number is not None:
|
||||||
|
refs.add(branch_number)
|
||||||
|
owner, _, repo = full_name.partition("/")
|
||||||
|
results: list[str] = []
|
||||||
|
for number in sorted(refs):
|
||||||
|
try:
|
||||||
|
issue = await gitea.get_issue(owner, repo, number)
|
||||||
|
except Exception:
|
||||||
|
results.append(f"#{number}:unknown")
|
||||||
|
continue
|
||||||
|
if not _is_issue(issue):
|
||||||
|
results.append(f"#{number}:not-issue")
|
||||||
|
continue
|
||||||
|
if issue.get("state") != "closed":
|
||||||
|
try:
|
||||||
|
await gitea.set_issue_state(owner, repo, number, "closed")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"PR merge: closing %s#%s failed: %s", full_name, number, exc
|
||||||
|
)
|
||||||
|
outcome = _apply_external_transition(full_name, number, "done", "closed")
|
||||||
|
if outcome == "noop":
|
||||||
|
board = get_board_state(full_name)
|
||||||
|
placed = any(
|
||||||
|
c["issue_number"] == number
|
||||||
|
for cards in board.values()
|
||||||
|
for c in cards
|
||||||
|
)
|
||||||
|
if not placed:
|
||||||
|
move_issue(full_name, number, "done")
|
||||||
|
log_event(
|
||||||
|
full_name, number, "closed",
|
||||||
|
to_column="done", source="webhook",
|
||||||
|
)
|
||||||
|
outcome = "closed"
|
||||||
|
results.append(f"#{number}:{outcome}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.post("/webhooks/gitea")
|
@app.post("/webhooks/gitea")
|
||||||
async def gitea_webhook(request: Request):
|
async def gitea_webhook(request: Request):
|
||||||
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
|
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
|
||||||
@@ -1170,6 +1855,11 @@ async def gitea_webhook(request: Request):
|
|||||||
handled = _apply_external_transition(
|
handled = _apply_external_transition(
|
||||||
full_name, number, "backlog", "reopened"
|
full_name, number, "backlog", "reopened"
|
||||||
)
|
)
|
||||||
|
elif event == "pull_request" and action == "closed" and full_name:
|
||||||
|
pr = payload.get("pull_request") or {}
|
||||||
|
if pr.get("merged"):
|
||||||
|
results = await _apply_pr_merged(full_name, pr)
|
||||||
|
handled = "pr-merged:" + (",".join(results) if results else "none")
|
||||||
|
|
||||||
gitea.invalidate_cache()
|
gitea.invalidate_cache()
|
||||||
await hub.broadcast(
|
await hub.broadcast(
|
||||||
@@ -1764,6 +2454,45 @@ def _comment_payload(comment: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_code_sources(owner: str, repo: str):
|
||||||
|
"""Branches + PRs + recent commits of a repo (best effort each)."""
|
||||||
|
async def _safe(coro):
|
||||||
|
try:
|
||||||
|
return await coro
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("code links source failed for %s/%s: %s", owner, repo, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
branches, pulls, commits = await asyncio.gather(
|
||||||
|
_safe(gitea.list_branches(owner, repo)),
|
||||||
|
_safe(gitea.list_pulls(owner, repo)),
|
||||||
|
_safe(gitea.list_commits(owner, repo)),
|
||||||
|
)
|
||||||
|
return branches, pulls, commits
|
||||||
|
|
||||||
|
|
||||||
|
async def _annotate_code(owner: str, repo: str, columns: dict) -> None:
|
||||||
|
"""Attach a compact _code summary to each card issue (issue #1)."""
|
||||||
|
branches, pulls, commits = await _fetch_code_sources(owner, repo)
|
||||||
|
if not (branches or pulls or commits):
|
||||||
|
return
|
||||||
|
for cards in columns.values():
|
||||||
|
for issue in cards:
|
||||||
|
links = code_links_service.find_code_links(
|
||||||
|
branches, pulls, commits, issue["number"]
|
||||||
|
)
|
||||||
|
if links["branches"] or links["pulls"] or links["commits"]:
|
||||||
|
issue["_code"] = links["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/issues/{owner}/{repo}/{number}/code")
|
||||||
|
async def api_issue_code(owner: str, repo: str, number: int):
|
||||||
|
"""Branches, PRs and commits linked to a card (issue #1)."""
|
||||||
|
branches, pulls, commits = await _fetch_code_sources(owner, repo)
|
||||||
|
links = code_links_service.find_code_links(branches, pulls, commits, number)
|
||||||
|
return {"ok": True, **links}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/issues/{owner}/{repo}/{number}")
|
@app.get("/api/issues/{owner}/{repo}/{number}")
|
||||||
async def api_issue_detail(owner: str, repo: str, number: int):
|
async def api_issue_detail(owner: str, repo: str, number: int):
|
||||||
"""Issue detail + comments for the card panel (proxied from Gitea)."""
|
"""Issue detail + comments for the card panel (proxied from Gitea)."""
|
||||||
@@ -1822,7 +2551,28 @@ async def api_events(repo: str = "", limit: int = 100):
|
|||||||
@app.get("/api/metrics")
|
@app.get("/api/metrics")
|
||||||
async def api_metrics(repo: str = "", window: int = 30):
|
async def api_metrics(repo: str = "", window: int = 30):
|
||||||
"""Throughput / cycle-time metrics from the journal."""
|
"""Throughput / cycle-time metrics from the journal."""
|
||||||
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
metrics = get_metrics(repo or None, window)
|
||||||
|
weekly = forecast_service.weekly_closures(
|
||||||
|
get_events(repo_full_name=repo or None, limit=2000)
|
||||||
|
)
|
||||||
|
metrics["weekly_closures"] = [b["count"] for b in weekly]
|
||||||
|
stats = forecast_service.throughput_stats(weekly)
|
||||||
|
metrics["throughput_4w"] = stats
|
||||||
|
return {"ok": True, "metrics": metrics}
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_forecast_label(repo_full_name: str, remaining: int) -> str | None:
|
||||||
|
"""Backlog burn-down ETA label for one repo (journal-based)."""
|
||||||
|
weekly = forecast_service.weekly_closures(
|
||||||
|
get_events(repo_full_name=repo_full_name, limit=2000)
|
||||||
|
)
|
||||||
|
est = forecast_service.estimate(remaining, weekly)
|
||||||
|
if est is None:
|
||||||
|
return None
|
||||||
|
if est.get("done"):
|
||||||
|
return "✅ rien d'ouvert"
|
||||||
|
label = forecast_service.eta_label(est)
|
||||||
|
return f"backlog épuisé {label}" if label else None
|
||||||
|
|
||||||
|
|
||||||
# ── Label management ───────────────────────────────────────
|
# ── Label management ───────────────────────────────────────
|
||||||
|
|||||||
+37
-1
@@ -79,6 +79,13 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||||
ON board_state(repo_full_name, column_name);
|
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 (
|
CREATE TABLE IF NOT EXISTS kv_store (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
@@ -239,6 +246,35 @@ def reconcile_board_state(
|
|||||||
return changed
|
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:
|
def get_kv(key: str) -> str | None:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
@@ -258,7 +294,7 @@ def set_kv(key: str, value: str) -> None:
|
|||||||
|
|
||||||
# ── Event journal ──────────────────────────────────────────
|
# ── Event journal ──────────────────────────────────────────
|
||||||
|
|
||||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||||
|
|
||||||
|
|
||||||
def log_event(
|
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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<div class="card-meta">
|
||||||
{% if issue.assignee %}
|
{% if issue.assignee %}
|
||||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
<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 %}"
|
<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>
|
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,12 +16,16 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -20,11 +20,14 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
{% if repo_info.html_url %}
|
{% if repo_info.html_url %}
|
||||||
@@ -51,6 +54,10 @@
|
|||||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
<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>
|
||||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||||
@@ -158,6 +165,7 @@
|
|||||||
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
||||||
<div id="detail-label-toggles" class="label-toggles"></div>
|
<div id="detail-label-toggles" class="label-toggles"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="detail-code" class="detail-code"></div>
|
||||||
<div id="detail-body" class="detail-body"></div>
|
<div id="detail-body" class="detail-body"></div>
|
||||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||||
<div id="detail-comments"></div>
|
<div id="detail-comments"></div>
|
||||||
@@ -176,6 +184,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
<script src="/static/js/board-live.js" defer></script>
|
<script src="/static/js/board-live.js" defer></script>
|
||||||
|
<script src="/static/js/today.js" defer></script>
|
||||||
{% include "_palette.html" %}
|
{% include "_palette.html" %}
|
||||||
<script src="/static/js/palette.js" defer></script>
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,11 +17,14 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -13,12 +13,16 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -16,12 +16,16 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -86,6 +90,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
<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 %}
|
{% if full in stats %}
|
||||||
<div class="mini-progress">
|
<div class="mini-progress">
|
||||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
||||||
|
|||||||
@@ -19,9 +19,12 @@
|
|||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<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>
|
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Prévisions — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill active">🔮 Prévisions</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||||
|
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for project in projects %}
|
||||||
|
<section class="briefing-card forecast-card">
|
||||||
|
<h2>📂 {{ project.full_name }}</h2>
|
||||||
|
<div class="forecast-flex">
|
||||||
|
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
|
||||||
|
{% for bar in project.bars %}
|
||||||
|
{% set px = (bar.h * 0.42)|round|int %}
|
||||||
|
<rect x="{{ loop.index0 * 21 }}" y="{{ 44 - px }}" width="15" height="{{ px if px > 0 else 1 }}" rx="2">
|
||||||
|
<title>{{ bar.label }} : {{ bar.count }} fermée(s)</title>
|
||||||
|
</rect>
|
||||||
|
{% endfor %}
|
||||||
|
</svg>
|
||||||
|
<div class="forecast-nums">
|
||||||
|
{% if project.stats %}
|
||||||
|
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||||
|
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if project.milestones %}
|
||||||
|
<ul class="forecast-milestones">
|
||||||
|
{% for ms in project.milestones %}
|
||||||
|
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
|
||||||
|
</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -16,11 +16,14 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<!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>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill active">⚙️ Règles</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>⚙️ 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>
|
||||||
@@ -16,11 +16,14 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Récit de sprint — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<script src="/static/vendor/marked.min.js"></script>
|
||||||
|
<script src="/static/vendor/purify.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
{% if markdown %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>📖 {{ milestone_title }}</h1>
|
||||||
|
<p>
|
||||||
|
<button type="button" class="btn-save btn-inline" id="sprint-copy">📋 Copier le markdown</button>
|
||||||
|
<button type="button" class="btn-save btn-inline" id="sprint-download">⬇️ Télécharger (.md)</button>
|
||||||
|
<a class="btn-save btn-inline" style="text-decoration:none" href="/board/{{ full_name }}?milestone={{ milestone_param|urlencode }}">🎯 Voir le board du milestone</a>
|
||||||
|
</p>
|
||||||
|
<p class="notes-hint">Le texte est archivable tel quel dans ObsiGate — c'est le markdown ci-dessous, rendu pour lecture.</p>
|
||||||
|
</section>
|
||||||
|
<section class="briefing-card sprint-render" id="sprint-render"></section>
|
||||||
|
<script>
|
||||||
|
const SPRINT_MD = {{ markdown|tojson }};
|
||||||
|
document.getElementById('sprint-render').innerHTML =
|
||||||
|
DOMPurify.sanitize(marked.parse(SPRINT_MD));
|
||||||
|
document.getElementById('sprint-copy').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(SPRINT_MD);
|
||||||
|
document.getElementById('sprint-copy').textContent = '✅ Copié !';
|
||||||
|
} catch (e) { alert('Copie impossible : ' + e); }
|
||||||
|
});
|
||||||
|
document.getElementById('sprint-download').addEventListener('click', () => {
|
||||||
|
const blob = new Blob([SPRINT_MD], { type: 'text/markdown' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'recit-{{ full_name|replace('/', '-') }}-{{ milestone_param|urlencode }}.md';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% else %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>📖 Récit de sprint — {{ full_name }}</h1>
|
||||||
|
<p>Choisis un milestone pour générer son récit :</p>
|
||||||
|
<ul>
|
||||||
|
{% for m in milestones %}
|
||||||
|
<li><a href="/sprint/{{ full_name }}?milestone={{ m.title|urlencode }}">🎯 {{ m.title }}</a> — {{ m.closed_issues or 0 }} fermée(s), {{ m.open_issues or 0 }} ouverte(s)</li>
|
||||||
|
{% else %}
|
||||||
|
<li>Aucun milestone dans ce projet.</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Kanbante v{{ version }} · le récit est écrit par le journal, pas par la mémoire des gens</span>
|
||||||
|
</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!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>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h1>🗣️ 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,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Aujourd'hui — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<a href="/" class="logo">📋 Kanbante</a>
|
||||||
|
<nav class="nav-pills">
|
||||||
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill active">🌅 Aujourd'hui</a>
|
||||||
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="today-main">
|
||||||
|
<h1 class="today-title">🌅 {{ date_label }}</h1>
|
||||||
|
<p class="today-sub">Trois cartes, pas une de plus. C'est le plan de la journée.</p>
|
||||||
|
|
||||||
|
{% if picks %}
|
||||||
|
<section class="today-picks">
|
||||||
|
{% for pick in picks %}
|
||||||
|
<article class="today-card{% if pick.state == 'closed' %} done{% endif %}">
|
||||||
|
<div class="today-card-top">
|
||||||
|
<span class="today-card-repo">{{ pick.repo }}</span>
|
||||||
|
<span class="today-card-col">{{ pick.column_label }}</span>
|
||||||
|
</div>
|
||||||
|
<h2>{% if pick.state == 'closed' %}🎉 {% endif %}#{{ pick.number }} — {{ pick.title }}</h2>
|
||||||
|
<div class="today-card-actions">
|
||||||
|
<a href="/board/{{ pick.repo }}">📋 Ouvrir le board</a>
|
||||||
|
{% if pick.html_url %}<a href="{{ pick.html_url }}" target="_blank" rel="noopener">Gitea ↗</a>{% endif %}
|
||||||
|
<button type="button" class="btn-sm" data-unpin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Retirer</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<p>Aucune carte épinglée pour aujourd'hui. Épingle-en jusqu'à 3 depuis un board (bouton 📌 sur les cartes) ou depuis les candidates ci-dessous.</p>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recap %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>🌆 Ça a bougé aujourd'hui</h2>
|
||||||
|
<ul>
|
||||||
|
{% for event in recap %}
|
||||||
|
<li>{{ event.created_at[11:16] }} — {{ event.repo_full_name }} #{{ event.issue_number }} : {{ event.event_type }}{% if event.to_column %} → {{ column_labels.get(event.to_column, event.to_column) }}{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if carry_over %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>↩️ À reprendre d'hier</h2>
|
||||||
|
<ul class="today-list">
|
||||||
|
{% for pick in carry_over %}
|
||||||
|
<li>
|
||||||
|
<span>{{ pick.repo }} #{{ pick.number }} — {{ pick.title }} <em>({{ pick.column_label }})</em></span>
|
||||||
|
{% if picks|length < 3 %}<button type="button" class="btn-sm" data-pin data-repo="{{ pick.repo }}" data-number="{{ pick.number }}">📌 Reprendre aujourd'hui</button>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if candidates and picks|length < 3 %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>🎯 Candidates (encours actuel)</h2>
|
||||||
|
<ul class="today-list">
|
||||||
|
{% for card in candidates %}
|
||||||
|
<li>
|
||||||
|
<span>{{ card.repo }} #{{ card.number }} — {{ card.title }} <em>({{ card.column_label }})</em></span>
|
||||||
|
<button type="button" class="btn-sm" data-pin data-repo="{{ card.repo }}" data-number="{{ card.number }}">📌 Épingler</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<span>Kanbante v{{ version }} · le focus du jour se réinitialise chaque matin — les cartes non terminées reviennent en proposition</span>
|
||||||
|
</footer>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
<script src="/static/js/today.js" defer></script>
|
||||||
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -13,12 +13,16 @@
|
|||||||
<nav class="nav-pills">
|
<nav class="nav-pills">
|
||||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||||
|
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
|
<a href="/standup" class="nav-pill">🗣️ Standup</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||||
|
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||||
|
<a href="/rules" class="nav-pill">⚙️ Règles</a>
|
||||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</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>
|
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.11.0"
|
version = "0.13.0"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1106,3 +1106,48 @@ footer {
|
|||||||
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
|
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
|
||||||
padding: 6px 16px; font-size: 0.95rem; z-index: 51;
|
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; }
|
||||||
|
|||||||
@@ -369,12 +369,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
fillDetail(data.issue, data.comments);
|
fillDetail(data.issue, data.comments);
|
||||||
|
loadCodeLinks(repoFull, number);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
||||||
showToast('❌ Détail indisponible : ' + err.message, true);
|
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 : ''}`);
|
||||||
|
}
|
||||||
|
box.innerHTML = parts.length
|
||||||
|
? '<h3 class="detail-comments-title">🧬 Code lié</h3>' + parts.map(p => `<div class="code-line">${p}</div>`).join('')
|
||||||
|
: '';
|
||||||
|
} catch (err) { /* le code est un bonus, jamais bloquant */ }
|
||||||
|
}
|
||||||
|
|
||||||
function fillDetail(issue, comments) {
|
function fillDetail(issue, comments) {
|
||||||
document.getElementById('detail-title').textContent = issue.title;
|
document.getElementById('detail-title').textContent = issue.title;
|
||||||
const stateChip = document.getElementById('detail-state');
|
const stateChip = document.getElementById('detail-state');
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
var ACTIONS = [
|
var ACTIONS = [
|
||||||
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||||
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
{ 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: 'Mes issues', href: '/my' },
|
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
@@ -223,6 +223,49 @@ class FakeGitea:
|
|||||||
for label in removed:
|
for label in removed:
|
||||||
self.label_ops.append(("remove", number, label["name"]))
|
self.label_ops.append(("remove", number, label["name"]))
|
||||||
|
|
||||||
|
async def list_branches(self, owner, repo):
|
||||||
|
return [
|
||||||
|
{"name": "main"},
|
||||||
|
{"name": "feature/1-tache-un"},
|
||||||
|
{"name": "issue-5-focus"},
|
||||||
|
]
|
||||||
|
|
||||||
|
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"):
|
async def get_milestones(self, owner, repo, state="open"):
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
|
"""Palier 8 (0.11.0): navigation manuelle du Wallboard."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def _make_two_active_projects(fake_gitea):
|
def _make_two_active_projects(fake_gitea):
|
||||||
|
|||||||
@@ -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