Compare commits

..
4 Commits
Author SHA1 Message Date
Spark 24a1ebb9a6 chore(release): Kanbante 0.14.0 — branche depuis la carte, santé du flux
CI / test (push) Successful in 1m49s
2026-10-11 14:49:38 -04:00
Spark 4c807a4344 test(0.14.0): branche depuis la carte, dwell/zombies — 8 tests, 165 au total 2026-10-11 14:49:38 -04:00
Spark 6453b53df4 feat(health): santé du flux — goulots et cartes zombies (issue #9)
app/flow.py: séjours par colonne reconstruits du journal,
médianes, colonne la plus lente; zombies = aucune activité
(journal/Gitea) depuis KANBANTE_ZOMBIE_DAYS (30). Pages /health
et /health/{o}/{r} + actions rapides (commenter/backlog/fermer).
2026-10-11 14:49:38 -04:00
Spark 1932d11457 feat(branch): créer la branche depuis la carte (issue #3)
slugify + branch_name_for (issue-<n>-<slug>, reconnu par #1/#2);
client create_branch; POST /api/issues/{o}/{r}/{n}/branch (nom
libre validé, idempotent si existante); la carte passe en En
cours, journalisée source branch, étiquette synchronisée;
formulaire dans la section Code lié du panneau détail.
2026-10-11 14:49:38 -04:00
28 changed files with 828 additions and 12 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## 0.14.0 — 2026-10-11
### Nouveautés
- **Créer la branche depuis la carte** (issue #3) : le panneau
détail propose `issue-<n>-<slug du titre>` (slug ASCII, 40 car.
max, nom libre accepté après validation git-ref) et la crée
dans Gitea depuis la branche par défaut
(`POST /api/issues/{o}/{r}/{n}/branch`). La carte passe en
« En cours » (journalisé, source `branch`, étiquette synchronisée)
et la branche est aussitôt reconnue par le code lié (#1) et par
l'automatisation de fusion (#2). Réponse idempotente si la
branche existe déjà.
- **Santé du flux** (issue #9) : `app/flow.py` reconstruit les
séjours par colonne depuis le journal (entrée → déplacement /
fermeture) : temps médian et moyen par colonne, colonne la plus
lente (goulot, ≥ 2 séjours). **Cartes zombies** : ouvertes sans
activité (journal ou Gitea) depuis `KANBANTE_ZOMBIE_DAYS`
(défaut 30). Pages `/health` (résumé global) et
`/health/{owner}/{repo}` (détail + actions rapides sur les
zombies : commenter « toujours pertinent ? », remettre au
Backlog, fermer — via les API existantes).
8 tests ajoutés (165 au total). **La feuille de route
« Différenciation » est complète : 15/15.**
## 0.13.0 — 2026-10-11
### Nouveautés
+5
View File
@@ -23,6 +23,8 @@
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 🌿 **Branche depuis la carte** — le panneau détail crée `issue-<n>-<slug>` dans Gitea depuis la branche par défaut, déplace la carte en « En cours » et journalise ; la PR qui en naîtra sera reconnue automatiquement
- 🩺 **Santé du flux** — `/health` : temps médian par colonne (du journal), colonne la plus lente, et cartes zombies (aucune activité depuis `KANBANTE_ZOMBIE_DAYS`) avec actions rapides (commenter, remettre au backlog, fermer)
- 📥 **Mode Triage** — `/triage` : le backlog se vide carte par carte, plein cadre, au clavier (`1-5` colonnes, `L` étiquettes, `A` s'assigner, `X` fermer, `Entrée` suivante, `Z` annuler), progression et résumé de session ; décisions via les mêmes API que le board
- 🔄 **Board dynamique** — aucun refresh manuel : toute modification (autre session, Gitea, capture) est diffusée en SSE et appliquée **en place** avec animation des cartes (`/api/board/{owner}/{repo}` en JSON + fragments rendus serveur, diff DOM/FLIP) ; repli par sondage de 45 s sans webhooks ; drag local, filtres et panneau détail préservés
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
@@ -129,6 +131,7 @@ sudo systemctl enable --now docker
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_ZOMBIE_DAYS` | `30` | Jours sans activité après lesquels une carte ouverte est « zombie » sur `/health` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
@@ -183,6 +186,8 @@ kanbante/
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/issues/{o}/{r}/{n}/branch` | Créer la branche de la carte (`issue-<n>-<slug>` ou nom libre validé) et la passer en « En cours » |
| GET | `/health` · `/health/{o}/{r}` | Santé du flux : goulots (temps médian par colonne) et cartes zombies, global ou par projet |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
+21
View File
@@ -7,6 +7,7 @@ a token in a branch name (`feature/12-login`, `issue-12`, `12-fix`).
"""
import re
import unicodedata
_REF_RE = re.compile(r"(?<![\w#])#(\d+)\b")
_BRANCH_TOKEN_RE = re.compile(r"(?:^|[-_/])(\d+)(?:[-_/]|$)")
@@ -27,6 +28,26 @@ def branch_issue_number(branch_name: str | None) -> int | None:
return int(m.group(1)) if m else None
def slugify_title(title: str, max_len: int = 40) -> str:
"""ASCII slug for a branch name: 'Créer la branche !' -> 'creer-la-branche'."""
normalized = unicodedata.normalize("NFKD", title or "")
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if len(slug) > max_len:
slug = slug[:max_len].rstrip("-")
return slug or "issue"
def branch_name_for(number: int, title: str) -> str:
"""The branch a card would create (issue #3): issue-<n>-<slug>.
The name is recognized back by branch_issue_number, which is
what links the future PR to this card (issue #2).
"""
return f"issue-{number}-{slugify_title(title)}"
def find_code_links(
branches: list[dict],
pulls: list[dict],
+6 -1
View File
@@ -78,6 +78,9 @@ Environment variables:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
"""
import os
@@ -86,7 +89,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.13.0"
APP_VERSION = "0.14.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -154,6 +157,7 @@ class Settings:
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
briefing_include_standup: bool = False
rules_raw: str = ""
zombie_days: int = 30
@property
def auth_enabled(self) -> bool:
@@ -198,4 +202,5 @@ def get_settings() -> Settings:
),
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
rules_raw=os.getenv("KANBANTE_RULES", ""),
zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30),
)
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.13.0"},
"clientInfo": {"name": "kanbante", "version": "0.14.0"},
},
req_id=1,
)
+123
View File
@@ -0,0 +1,123 @@
"""Flow health (roadmap #9): bottlenecks and zombie cards.
Boards show state; this module reads the event journal for health:
how long cards actually stay in each column (dwell samples are the
intervals between an entry and the next move/close), and which
open cards have had no activity for a long time (zombies).
"""
import statistics
from datetime import datetime
from app.models import KANBAN_COLUMNS
def parse_ts(raw) -> datetime | None:
"""Parse journal ('YYYY-MM-DD HH:MM:SS') and Gitea ISO stamps."""
text = str(raw or "").strip()
if not text:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(text[:19], fmt)
except ValueError:
continue
try:
return datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return None
def column_dwell(events: list[dict]) -> dict[str, list[float]]:
"""Dwell samples (in days) per column, rebuilt from the journal.
A stay starts when a card enters a column (created / moved /
reopened) and ends at its next move or close. The first stay of
a card whose journal starts mid-flight is unknowable and is
simply not sampled.
"""
by_issue: dict[tuple[str, int], list[dict]] = {}
for event in events:
by_issue.setdefault(
(event["repo_full_name"], event["issue_number"]), []
).append(event)
dwell: dict[str, list[float]] = {}
for issue_events in by_issue.values():
ordered = sorted(
issue_events, key=lambda e: str(e.get("created_at") or "")
)
current: str | None = None
since: datetime | None = None
for event in ordered:
at = parse_ts(event.get("created_at"))
if at is None:
continue
kind = event["event_type"]
if kind == "created":
if current is None:
current = event.get("to_column") or "backlog"
since = at
elif kind == "moved":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
elif current is None and event.get("from_column"):
# The stay in from_column began before the journal
# window: only the new stay can be timed.
pass
current = event.get("to_column") or current
since = at
elif kind == "closed":
if current is not None and since is not None:
dwell.setdefault(current, []).append(
(at - since).total_seconds() / 86400
)
current = "done"
since = at
elif kind == "reopened":
current = event.get("to_column") or "backlog"
since = at
return dwell
def summarize_dwell(dwell: dict[str, list[float]]) -> list[dict]:
"""Median dwell per column, in board order."""
summary = []
for column in KANBAN_COLUMNS:
samples = dwell.get(column) or []
if not samples:
continue
summary.append(
{
"column": column,
"count": len(samples),
"median_days": round(statistics.median(samples), 1),
"avg_days": round(sum(samples) / len(samples), 1),
}
)
return summary
def slowest_column(summary: list[dict], min_samples: int = 2) -> dict | None:
"""The bottleneck: slowest column (done excluded) with data."""
candidates = [
row for row in summary
if row["column"] != "done" and row["count"] >= min_samples
]
if not candidates:
return None
return max(candidates, key=lambda r: r["median_days"])
def last_activity(events: list[dict]) -> dict[tuple[str, int], datetime]:
"""Most recent journal timestamp per (repo, issue)."""
latest: dict[tuple[str, int], datetime] = {}
for event in events:
at = parse_ts(event.get("created_at"))
if at is None:
continue
key = (event["repo_full_name"], event["issue_number"])
if key not in latest or at > latest[key]:
latest[key] = at
return latest
+13
View File
@@ -341,6 +341,19 @@ class GiteaClient:
self._cache_clear()
return r.json()
async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict:
"""Create a branch from another one (issue #3)."""
return await self._request(
"POST",
f"/repos/{owner}/{repo}/branches",
json={
"new_branch_name": new_branch,
"old_branch_name": old_branch,
},
)
async def list_branches(self, owner: str, repo: str) -> list[dict]:
"""All branches of a repo (code links, issue #1)."""
return await self._get_all_pages(f"/repos/{owner}/{repo}/branches")
+229 -1
View File
@@ -37,6 +37,7 @@ from app.config import APP_VERSION, get_settings
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 flow as flow_service
from app import forecast as forecast_service
from app import rules as rules_service
from app import sprint as sprint_service
@@ -1252,6 +1253,94 @@ async def api_rules_run(request: Request, csrf_token: str = Form("")):
return {"ok": True, "summary": summary}
async def _project_health(owner: str, repo: str) -> dict:
"""Bottlenecks + zombies for one project (issue #9)."""
full_name = f"{owner}/{repo}"
settings = get_settings()
all_items = await gitea.get_repo_issues(owner, repo, state="all")
columns, display_issues, _bs = _board_columns_for(
owner, repo, all_items
)
events = get_events(repo_full_name=full_name, limit=2000)
dwell = flow_service.summarize_dwell(flow_service.column_dwell(events))
slowest = flow_service.slowest_column(dwell)
activity = flow_service.last_activity(events)
placement = {
issue["number"]: col
for col, cards in columns.items()
for issue in cards
}
now = datetime.now()
zombies = []
for issue in display_issues:
number = issue["number"]
candidates = [
ts for ts in (
activity.get((full_name, number)),
flow_service.parse_ts(issue.get("updated_at")),
flow_service.parse_ts(issue.get("created_at")),
)
if ts is not None
]
if not candidates:
continue
days = (now - max(candidates)).days
if days >= settings.zombie_days:
column = placement.get(number, "backlog")
zombies.append(
{
"number": number,
"title": issue.get("title", ""),
"column": column,
"column_label": COLUMN_LABELS[column],
"days_inactive": days,
"html_url": issue.get("html_url", ""),
}
)
zombies.sort(key=lambda z: -z["days_inactive"])
return {
"full_name": full_name,
"dwell": dwell,
"slowest": slowest,
"zombies": zombies,
"zombie_days": settings.zombie_days,
"open_count": len(display_issues),
"column_labels": COLUMN_LABELS,
}
@app.get("/health", response_class=HTMLResponse)
async def health_index(request: Request):
"""Flow health, global summary (issue #9)."""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
projects = []
for repo_info in active:
owner, _, name = repo_info["full_name"].partition("/")
try:
projects.append(await _project_health(owner, name))
except Exception as exc:
logger.warning("Health failed for %s: %s", repo_info["full_name"], exc)
return render(request, "health.html", projects=projects)
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
async def health_project(request: Request, owner: str, repo: str):
"""Flow health of one project: dwell per column + zombies."""
repos = await gitea.search_repos()
known = {r["full_name"] for r in repos}
if f"{owner}/{repo}" not in known:
return render(
request, "health_project.html",
health=None, full_name=f"{owner}/{repo}",
)
health = await _project_health(owner, repo)
return render(
request, "health_project.html",
health=health, full_name=f"{owner}/{repo}",
)
@app.get("/api/standup")
async def api_standup():
"""The automatic standup as JSON text (issue #6)."""
@@ -2490,7 +2579,146 @@ 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}
suggested = f"issue-{number}"
try:
issue = await gitea.get_issue(owner, repo, number)
suggested = code_links_service.branch_name_for(
number, issue.get("title", "")
)
except Exception:
pass
return {"ok": True, "suggested_branch": suggested, **links}
_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
async def _sync_kanban_label(
owner: str, repo: str, number: int, column: str
) -> None:
"""Mirror a column on the issue's kanban:* label (best effort)."""
if not get_settings().sync_labels:
return
try:
desired = (
f"kanban:{column}" if column in LABELLED_COLUMNS else None
)
issue = await gitea.get_issue(owner, repo, number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await gitea.remove_issue_label(
owner, repo, number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await gitea.ensure_label(
owner, repo, desired, COLUMN_COLORS[column]
)
await gitea.add_issue_label(owner, repo, number, label["id"])
except Exception as exc:
logger.warning(
"Label sync failed for %s/%s#%s: %s", owner, repo, number, exc
)
@app.post("/api/issues/{owner}/{repo}/{number}/branch")
async def api_create_branch(
owner: str,
repo: str,
number: int,
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
"""Create the card's branch in Gitea (issue #3), then start the card.
Default name: issue-<n>-<slug of the title> — the convention the
merged-PR automation (issue #2) recognizes. Creating the branch
moves the card to In Progress and journals it.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
try:
issue = await gitea.get_issue(owner, repo, number)
except Exception:
return JSONResponse(
{"ok": False, "error": "Issue introuvable"}, status_code=404
)
if not _is_issue(issue):
return JSONResponse(
{"ok": False, "error": "Pas une issue"}, status_code=400
)
branch = (name or "").strip() or code_links_service.branch_name_for(
number, issue.get("title", "")
)
if (
not _BRANCH_NAME_RE.match(branch)
or ".." in branch
or branch.endswith("/")
or branch.endswith(".lock")
):
return JSONResponse(
{"ok": False, "error": f"Nom de branche invalide : {branch}"},
status_code=400,
)
repo_info = {}
try:
repo_info = await gitea.get_repo(owner, repo)
except Exception:
pass
base_url = repo_info.get("html_url", "")
branch_url = f"{base_url}/src/branch/{branch}" if base_url else ""
existing = await _safe_list_branches(owner, repo)
if branch in existing:
return {
"ok": True, "branch": branch, "exists": True,
"url": branch_url, "moved": False,
}
default_branch = repo_info.get("default_branch") or "main"
try:
await gitea.create_branch(owner, repo, branch, default_branch)
except Exception as exc:
return JSONResponse(
{"ok": False, "error": f"Création impossible : {exc}"},
status_code=502,
)
gitea.invalidate_cache()
full_name = f"{owner}/{repo}"
moved = False
if issue.get("state") == "open":
board = get_board_state(full_name)
current = next(
(
col for col, cards in board.items()
if any(c["issue_number"] == number for c in cards)
),
"backlog",
)
if current != "in_progress":
move_issue(full_name, number, "in_progress")
log_event(
full_name, number, "moved",
from_column=current, to_column="in_progress",
source="branch",
)
await _sync_kanban_label(owner, repo, number, "in_progress")
moved = True
await _publish_board_changed(full_name)
return {
"ok": True, "branch": branch, "from": default_branch,
"url": branch_url, "moved": moved,
}
async def _safe_list_branches(owner: str, repo: str) -> list[str]:
try:
return [b.get("name", "") for b in await gitea.list_branches(owner, repo)]
except Exception:
return []
@app.get("/api/issues/{owner}/{repo}/{number}")
+1
View File
@@ -26,6 +26,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -28,6 +28,7 @@
<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="/health" class="nav-pill">🩺 Santé</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
+1
View File
@@ -25,6 +25,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -23,6 +23,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -26,6 +26,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -25,6 +25,7 @@
<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="/health" class="nav-pill">🩺 Santé</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
+53
View File
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé du flux — 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">⚙️ Règles</a>
<a href="/health" class="nav-pill active">🩺 Santé</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>🩺 Santé du flux</h1>
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
</section>
{% for project in projects %}
<section class="briefing-card forecast-card">
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
{% if project.slowest %}
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
{% else %}
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
{% endif %}
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
</section>
{% else %}
<div class="briefing-card"><p>Aucun projet actif.</p></div>
{% endfor %}
</main>
<footer>
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Santé — {{ full_name }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<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="/health" class="nav-pill active">🩺 Santé</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
{% if health is none %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<p>Projet introuvable.</p>
</section>
{% else %}
<section class="briefing-card">
<h1>🩺 {{ full_name }}</h1>
<h2>⏱️ Temps passé par colonne (journal)</h2>
{% if health.dwell %}
<table class="health-table">
<thead><tr><th>Colonne</th><th>Séjours mesurés</th><th>Médiane</th><th>Moyenne</th></tr></thead>
<tbody>
{% for row in health.dwell %}
<tr{% if health.slowest and row.column == health.slowest.column %} class="health-slowest"{% endif %}>
<td>{{ health.column_labels[row.column] }}{% if health.slowest and row.column == health.slowest.column %} 🐌{% endif %}</td>
<td>{{ row.count }}</td>
<td>{{ row.median_days }} j</td>
<td>{{ row.avg_days }} j</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if health.slowest %}
<p>🐌 <strong>Goulot : {{ health.column_labels[health.slowest.column] }}</strong> — c'est là que les cartes dorment le plus longtemps (médiane {{ health.slowest.median_days }} j).</p>
{% endif %}
{% else %}
<p>Pas encore de séjours mesurables dans le journal de ce projet — il faut des déplacements journalisés pour calculer les temps de colonne.</p>
{% endif %}
</section>
<section class="briefing-card">
<h2>🧟 Cartes zombies — aucune activité depuis ≥ {{ health.zombie_days }} jours</h2>
{% if health.zombies %}
<ul class="today-list">
{% for z in health.zombies %}
<li>
<span>#{{ z.number }} — {{ z.title }} <em>({{ z.column_label }} · inactive depuis {{ z.days_inactive }} j)</em></span>
<span class="zombie-actions">
<button type="button" class="btn-sm" data-zombie="comment" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">💬 Toujours pertinent ?</button>
<button type="button" class="btn-sm" data-zombie="backlog" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">↩️ Backlog</button>
<button type="button" class="btn-sm" data-zombie="close" data-repo="{{ full_name }}" data-number="{{ z.number }}" data-column="{{ z.column }}">✅ Fermer</button>
</span>
</li>
{% endfor %}
</ul>
<p class="notes-hint">« Fermer » déplace la carte en Terminé (l'issue est fermée dans Gitea par la synchro habituelle). « Toujours pertinent ? » poste un commentaire du même texte sur l'issue.</p>
{% else %}
<p>Aucune carte zombie. Le flux est propre. ✨</p>
{% endif %}
</section>
{% endif %}
</main>
<footer>
<span>Kanbante v{{ version }} · seuil zombie : KANBANTE_ZOMBIE_DAYS</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/health.js" defer></script>
</body>
</html>
+1
View File
@@ -24,6 +24,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -24,6 +24,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -17,6 +17,7 @@
<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>
<a href="/health" class="nav-pill">🩺 Santé</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
+1
View File
@@ -23,6 +23,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1
View File
@@ -23,6 +23,7 @@
<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="/health" class="nav-pill">🩺 Santé</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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.13.0"
version = "0.14.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+13
View File
@@ -1151,3 +1151,16 @@ footer {
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
/* ── Branche depuis la carte (0.14.0, #3) ── */
.branch-input {
background: var(--surface-3, #1d2942); color: var(--text-1);
border: 1px solid var(--border, #22304a); border-radius: 6px;
padding: 3px 8px; font-family: monospace; font-size: 0.85rem;
}
/* ── Santé du flux (0.14.0, #9) ── */
.health-table { width: 100%; border-collapse: collapse; margin: 10px 0; }
.health-table th, .health-table td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border, #22304a); }
.health-slowest td { color: var(--warn, #e8a33d); font-weight: 600; }
.zombie-actions { display: flex; gap: 6px; flex-shrink: 0; }
+31 -3
View File
@@ -397,9 +397,37 @@ document.addEventListener('DOMContentLoaded', () => {
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('')
: '';
const hasBranch = (data.branches || []).length > 0;
if (!hasBranch && data.suggested_branch) {
parts.push(`🌿 <input id="branch-name" class="branch-input" value="${data.suggested_branch}" size="36" spellcheck="false"> <button type="button" class="btn-sm" id="branch-create">Créer la branche</button>`);
}
box.innerHTML = '<h3 class="detail-comments-title">🧬 Code lié</h3>' + (
parts.length
? parts.map(p => `<div class="code-line">${p}</div>`).join('')
: '<div class="code-line">Aucun élément lié pour l\'instant.</div>'
);
const createBtn = document.getElementById('branch-create');
if (createBtn) {
createBtn.addEventListener('click', async () => {
const name = (document.getElementById('branch-name') || {}).value || '';
const form = new FormData();
form.append('name', name);
form.append('csrf_token', csrfToken);
createBtn.disabled = true;
try {
const r2 = await fetch(`/api/issues/${owner}/${repo}/${number}/branch`, { method: 'POST', body: form });
const body = await r2.json().catch(() => ({}));
if (!r2.ok || !body.ok) throw new Error(body.error || ('HTTP ' + r2.status));
showToast(body.exists
? `🌿 La branche ${body.branch} existait déjà`
: `🌿 Branche ${body.branch} créée depuis ${body.from} — carte en « En cours »`);
loadCodeLinks(repoFull, number);
} catch (err) {
createBtn.disabled = false;
showToast('❌ Branche non créée : ' + err.message, true);
}
});
}
} catch (err) { /* le code est un bonus, jamais bloquant */ }
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Kanbante — Flow health (issue #9): quick actions on zombie cards.
* Close/backlog reuse /api/move; the nudge reuses the comments API.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
function post(url, fields) {
var data = new FormData();
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-zombie]');
if (!btn) return;
var repo = btn.getAttribute('data-repo');
var number = btn.getAttribute('data-number');
var column = btn.getAttribute('data-column');
var action = btn.getAttribute('data-zombie');
var parts = repo.split('/');
btn.disabled = true;
var done = function () { location.reload(); };
var fail = function (err) { btn.disabled = false; alert(err.message); };
if (action === 'comment') {
post('/api/issues/' + parts[0] + '/' + parts[1] + '/' + number + '/comments', {
body: '🧟 Cette carte est inactive depuis un moment — toujours pertinent ?'
}).then(done).catch(fail);
} else {
post('/api/move', {
repo_full_name: repo,
issue_number: number,
from_column: column,
to_column: action === 'close' ? 'done' : 'backlog',
to_order: number
}).then(done).catch(fail);
}
});
})();
+1
View File
@@ -17,6 +17,7 @@
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '🩺', label: 'Santé du flux — goulots et zombies', href: '/health' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
+9 -5
View File
@@ -38,6 +38,7 @@ def _repo(name: str, updated: datetime, archived: bool = False) -> dict:
"owner": {"login": "Projets"},
"archived": archived,
"html_url": f"http://gitea.test/Projets/{name}",
"default_branch": "main",
}
@@ -72,6 +73,7 @@ class FakeGitea:
_repo("vieux", now - timedelta(days=800)),
_repo("archive", now - timedelta(days=1), archived=True),
]
self.branches = ["main", "feature/1-tache-un", "issue-5-focus"]
self.issues = {
"Projets/demo": [
_issue(1, "Tâche un"),
@@ -224,11 +226,13 @@ class FakeGitea:
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"},
]
return [{"name": n} for n in self.branches]
async def create_branch(self, owner, repo, new_branch, old_branch):
if new_branch in self.branches:
raise RuntimeError("branch exists")
self.branches.append(new_branch)
return {"name": new_branch}
async def list_pulls(self, owner, repo, state="all"):
return [
+159
View File
@@ -0,0 +1,159 @@
"""Palier 11 (0.14.0): branche depuis la carte (#3), santé du flux (#9)."""
from datetime import datetime, timedelta
from app import code_links as cl
from app import flow
from app import models
from tests.conftest import csrf_from
# ── #3 Branche depuis la carte ────────────────────────────────────
def test_slugify_and_branch_name():
assert cl.slugify_title("Créer la branche depuis la carte") == \
"creer-la-branche-depuis-la-carte"
assert cl.slugify_title("Fix: l'été indien (urgent!)") == \
"fix-l-ete-indien-urgent"
assert cl.slugify_title("Un titre vraiment très long qui dépasse" * 3)
assert len(cl.slugify_title("x" * 100)) == 40
assert cl.slugify_title("!!!") == "issue"
assert cl.branch_name_for(12, "Capture éclair") == "issue-12-capture-eclair"
# La convention est reconnue par l'appariement (#1/#2).
assert cl.branch_issue_number(cl.branch_name_for(12, "Capture")) == 12
def test_create_branch_endpoint(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r.status_code == 200
data = r.json()
assert data["branch"] == "issue-5-tache-focus"
assert data["from"] == "main"
assert data["moved"] is True
assert "issue-5-tache-focus" in fake_gitea.branches
board = models.get_board_state("Projets/demo")
assert 5 in [c["issue_number"] for c in board["in_progress"]]
events = models.get_events(repo_full_name="Projets/demo")
assert any(
e["event_type"] == "moved" and e["source"] == "branch"
for e in events
)
# Deuxième appel : la branche existe déjà, pas de doublon.
r2 = client.post("/api/issues/Projets/demo/5/branch",
data={"csrf_token": token})
assert r2.json()["exists"] is True
def test_create_branch_custom_name_and_validation(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "feature/super-truc"})
assert r.status_code == 200
assert r.json()["branch"] == "feature/super-truc"
r = client.post("/api/issues/Projets/demo/1/branch",
data={"csrf_token": token, "name": "mauvais nom.."})
assert r.status_code == 400
def test_code_endpoint_suggests_branch(client, fake_gitea):
r = client.get("/api/issues/Projets/demo/3/code")
assert r.json()["suggested_branch"] == "issue-3-ancienne-tache"
# ── #9 Santé du flux : fonctions pures ────────────────────────────
def _ev(day_offset: float, kind: str, frm=None, to=None, number=1):
at = (datetime(2026, 9, 1) + timedelta(days=day_offset)).strftime(
"%Y-%m-%d %H:%M:%S"
)
return {
"repo_full_name": "Projets/demo", "issue_number": number,
"event_type": kind, "from_column": frm, "to_column": to,
"created_at": at,
}
def test_column_dwell_reconstruction():
events = [
_ev(0, "created", to="backlog"),
_ev(4, "moved", frm="backlog", to="in_progress"),
_ev(10, "closed", frm="in_progress", to="done"),
_ev(1, "created", to="backlog", number=2),
_ev(3, "moved", frm="backlog", to="review", number=2),
]
dwell = flow.column_dwell(events)
assert dwell["backlog"] == [4.0, 2.0]
assert dwell["in_progress"] == [6.0]
summary = flow.summarize_dwell(dwell)
by_col = {row["column"]: row for row in summary}
assert by_col["backlog"]["median_days"] == 3.0
assert by_col["backlog"]["count"] == 2
# backlog a 2 échantillons (médiane 3 j) : c'est le seul goulot
# nommable à ce stade (in_progress n'a qu'un séjour mesuré).
slowest = flow.slowest_column(summary)
assert slowest["column"] == "backlog"
events.append(_ev(20, "moved", frm="review", to="done", number=2))
events += [
_ev(0, "created", to="backlog", number=3),
_ev(2, "moved", frm="backlog", to="review", number=3),
_ev(19, "moved", frm="review", to="done", number=3),
]
dwell = flow.column_dwell(events)
summary = flow.summarize_dwell(dwell)
slowest = flow.slowest_column(summary)
assert slowest["column"] == "review"
assert slowest["median_days"] == 17.0
def test_last_activity():
events = [_ev(0, "created", to="backlog"), _ev(5, "moved", to="todo")]
latest = flow.last_activity(events)
assert latest[("Projets/demo", 1)] == datetime(2026, 9, 6)
# ── #9 Pages santé ────────────────────────────────────────────────
def _seed_stay(number: int, days_ago_start: int, days_ago_end: int,
frm="backlog", to="review"):
models.log_event("Projets/demo", number, "created", to_column="backlog")
models.log_event("Projets/demo", number, "moved",
from_column=frm, to_column=to)
now = datetime.now()
with models.get_db() as conn:
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'created'",
((now - timedelta(days=days_ago_start)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
conn.execute(
"UPDATE board_events SET created_at = ? "
"WHERE issue_number = ? AND event_type = 'moved'",
((now - timedelta(days=days_ago_end)).strftime("%Y-%m-%d %H:%M:%S"), number),
)
def test_health_pages_and_bottleneck(client, fake_gitea):
_seed_stay(1, 40, 10) # 30 j en backlog
_seed_stay(5, 35, 5) # 30 j en backlog aussi
page = client.get("/health/Projets/demo")
assert page.status_code == 200
assert "backlog" in page.text.lower()
index = client.get("/health")
assert index.status_code == 200
assert "Projets/demo" in index.text
def test_health_zombies_threshold(client, fake_gitea, monkeypatch):
# Les issues du faux datent du 2026-09-02 : ~39 j d'inactivité,
# donc zombies au seuil par défaut de 30 j.
page = client.get("/health/Projets/demo")
assert "Tâche un" in page.text
assert 'data-zombie="close"' in page.text
monkeypatch.setenv("KANBANTE_ZOMBIE_DAYS", "10000")
page = client.get("/health/Projets/demo")
assert "Aucune carte zombie" in page.text