10 Commits
Author SHA1 Message Date
Spark 5b41c5ea74 chore(release): Kanbante 0.13.0 — code lié, PR fusionnée, standup, règles
CI / test (push) Successful in 1m43s
2026-10-11 14:21:11 -04:00
Spark 370dafd266 test(0.13.0): code lié, webhook PR, standup, règles — 10 tests, 157 au total 2026-10-11 14:21:11 -04:00
Spark 7eef8bb604 feat(rules): règles d'automatisation déclaratives (issue #8)
KANBANTE_RULES (JSON validé): conditions colonne/âge/étiquette/
WIP -> add_label / notify (garde quotidienne) / move_top;
évaluation 15 min + POST /api/rules/run; actions journalisées
(événement rule); page /rules. Aucune fermeture possible.
2026-10-11 14:21:11 -04:00
Spark 0b48026752 feat(standup): standup automatique hier/aujourd'hui/blocages (issue #6)
format_standup_text + _standup_data (journal de la veille, focus
du jour, vieillissantes QA/Révision); page /standup copiable,
/api/standup; inclusion optionnelle au briefing ntfy
(KANBANTE_BRIEFING_INCLUDE_STANDUP).
2026-10-11 14:21:11 -04:00
Spark 675ce09083 feat(code): cartes liées au code — branches, PRs, commits (issue #1)
app/code_links.py: appariement pur (#n dans titres/corps/messages,
numéro en jeton de branche). Client: list_branches/list_pulls/
list_commits. Badge sur les cartes (board + fragments live),
section Code lié du panneau détail, /api/issues/{o}/{r}/{n}/code.
2026-10-11 14:21:11 -04:00
Spark 03fa1eb44e chore(release): Kanbante 0.12.0 — focus du jour, prévisions, récit de sprint
CI / test (push) Successful in 1m43s
2026-10-11 13:55:03 -04:00
Spark 8e0c7b8f09 test(0.12.0): prévisions (fonctions pures + pages), focus du jour, récit — 11 tests, 147 au total 2026-10-11 13:55:03 -04:00
Spark 4c5d1dee23 feat(sprint): récit de sprint généré par le journal (issue #4)
app/sprint.py + /sprint/{o}/{r} (rendu markdown, copier/télécharger)
+ /api/sprint en JSON: livré avec temps de cycle, plus lentes,
reporté, chiffres de période. Lien depuis le bandeau milestone.
Navigation: pilules Aujourd'hui/Prévisions et actions palette.
2026-10-11 13:55:03 -04:00
Spark cdabc77ccd feat(forecast): prévisions au débit réel du journal (issue #7)
app/forecast.py: fermetures hebdo (8 sem.), débit moy/min/max sur
4 semaines complètes, ETA en fourchette, silence sans historique.
Dashboard (épuisement du backlog), bandeau milestone (alerte
échéance), page /forecast (sparkline SVG, verdicts), /api/metrics
étendu (weekly_closures, throughput_4w).
2026-10-11 13:55:03 -04:00
Spark fbe58bbf16 feat(today): focus du jour — le rituel des 3 cartes (issue #5)
Table today_picks; POST /api/today/pin (max 3, idempotent) et
/unpin; page /today (picks en direct, candidates de l'encours,
reprises d'hier, récap du jour depuis le journal); bouton 📌 sur
les cartes (délégation capture dans today.js, fragments live
compris); sections dédiées dans le briefing du matin.
2026-10-11 13:55:03 -04:00
37 changed files with 2373 additions and 8 deletions
+59
View File
@@ -1,5 +1,64 @@
# 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
### Nouveautés
+16
View File
@@ -16,6 +16,13 @@
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
- 🌅 **Focus du jour** — `/today` : jusqu'à 3 cartes épinglées (bouton 📌 sur les cartes), candidates de l'encours, reprises d'hier non terminées, récapitulatif du soir ; intégrées au briefing
- 🔮 **Prévisions** — débit réel du journal (fermetures/semaine), date d'épuisement du backlog et ETA des milestones en fourchette honnête, sur le dashboard, le bandeau milestone et `/forecast` (sparkline SVG)
- 📖 **Récit de sprint** — le bilan markdown d'un milestone (livré, cycles, plus lentes, reporté) généré depuis le journal, page `/sprint/...` + copie/téléchargement en un clic
- 🧬 **Code lié** — branches, PRs et commits référant `#n` sur la carte (badge `⑂`) et dans le panneau détail (section « Code lié »)
- 🔀 **PR fusionnée → Terminé** — le webhook `pull_request` retrouve les issues liées (références `#n`, branche `issue-n-*`), les ferme et déplace leurs cartes, journalisé
- 🗣️ **Standup automatique** — `/standup` : fermé/déplacé hier, focus du jour, blocages (QA/Révision qui vieillissent), copiable d'un clic ; ajout optionnel au briefing (`KANBANTE_BRIEFING_INCLUDE_STANDUP=1`)
- ⚙️ **Règles d'automatisation** — moteur déclaratif `KANBANTE_RULES` (JSON) évalué toutes les 15 min : conditions colonne/âge/étiquette/WIP → étiquette, notification ntfy, tête de colonne ; jamais de fermeture par règle ; page `/rules`
- 📥 **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
@@ -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_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_BRIEFING_INCLUDE_STANDUP` | `0` | `1` = le standup automatique est ajouté au briefing ntfy du matin |
| `KANBANTE_RULES` | *(vide)* | Règles d'automatisation en JSON (voir `/rules`) : conditions colonne/âge/étiquette/WIP → add_label / notify / move_top |
| `KANBANTE_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}` |
@@ -170,6 +179,13 @@ kanbante/
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
| GET | `/api/board/{owner}/{repo}` | Board en JSON + fragments HTML des cartes (base du board dynamique, `?milestone=` accepté) |
| GET | `/triage` · `/triage/{owner}/{repo}` | Mode Triage : sélecteur de projet puis session clavier sur le backlog |
| GET | `/today` | Focus du jour : les 3 cartes épinglées, candidates, reprises d'hier |
| GET | `/api/issues/{o}/{r}/{n}/code` | Branches, PRs et commits liés à la carte |
| GET | `/standup` · `/api/standup` | Standup automatique (hier / aujourd'hui / blocages) |
| GET | `/rules` · POST `/api/rules/run` | Règles d'automatisation : état, dernière évaluation, lancement manuel |
| POST | `/api/today/pin` · `/api/today/unpin` | Épingler / retirer une carte du Focus du jour (max 3) |
| GET | `/forecast` | Prévisions : débit hebdo, ETA backlog et milestones par projet |
| GET | `/sprint/{owner}/{repo}` · `/api/sprint/{owner}/{repo}` | Récit de sprint d'un milestone (`?milestone=`), page rendue ou JSON markdown |
| POST | `/api/issues/{o}/{r}/{n}/assign-self` | S'assigner une issue (utilisateur du jeton) |
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
+55
View File
@@ -16,6 +16,43 @@ def ntfy_configured(settings: Settings) -> bool:
return bool(settings.ntfy_url and settings.ntfy_topic)
def format_standup_text(data: dict) -> str:
"""Automatic standup (issue #6): yesterday / today / blockers."""
lines = [f"🗣️ Standup — {data['today_label']}"]
closed = data["closed_yesterday"]
lines.append(
f"\n✅ Hier ({data['yesterday_label']}) : {len(closed)} fermée(s), "
f"{data['moved_yesterday']} déplacement(s)"
)
for item in closed[:8]:
lines.append(
f" • {item['repo']} #{item['number']} « {item['title']} »"
)
lines.append("\n📌 Aujourd'hui :")
if data["picks"]:
for pick in data["picks"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
elif data["focus_titles"]:
for title in data["focus_titles"][:5]:
lines.append(f" • {title}")
lines.append(" (aucun focus épinglé — pense à /today)")
else:
lines.append(" • Rien en cours. Journée libre ou triage à faire !")
lines.append("\n🚧 Blocages :")
if data["blockers"]:
for card in data["blockers"][:6]:
lines.append(
f" • {card['repo']} #{card['number']} « {card['title']} »"
f" — {card['days']} j en {card['column_label']}"
)
else:
lines.append(" • Aucun blocage signalé. ✨")
return "\n".join(lines)
def format_briefing_text(data: dict) -> str:
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
@@ -27,6 +64,22 @@ def format_briefing_text(data: dict) -> str:
if activity.get("avg_cycle_days") is not None:
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
if data.get("today_picks"):
lines.append("\n🌅 Focus du jour :")
for pick in data["today_picks"]:
mark = "✅" if pick["state"] == "closed" else "📌"
lines.append(
f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
if data.get("carry_over"):
lines.append("\n↩️ À reprendre d'hier (non terminées) :")
for pick in data["carry_over"]:
lines.append(
f" • {pick['repo']} #{pick['number']} « {pick['title']} »"
f" — {pick['column_label']}"
)
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
for group in data["focus_groups"][:6]:
lines.append(f" • {group['full_name']} : {group['count']}")
@@ -51,6 +104,8 @@ def format_briefing_text(data: dict) -> str:
if data["focus_total"] == 0 and not data["aging"]:
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
if data.get("standup"):
lines.append("\n" + data["standup"])
return "\n".join(lines)
+106
View File
@@ -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
View File
@@ -68,6 +68,16 @@ Environment variables:
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
"{title}" and "{content}" placeholders
are substituted (JSON-escaped).
KANBANTE_BRIEFING_INCLUDE_STANDUP
"1" appends the automatic standup
(issue #6) to the morning briefing.
KANBANTE_RULES Automation rules (issue #8) as a JSON
array: [{"name": ..., "when":
{"column": "review", "min_age_days": 3},
"then": {"notify": true}}]. Conditions:
column, min_age_days, label, wip_over.
Actions: add_label, notify, move_top.
A rule can never close an issue.
"""
import os
@@ -76,7 +86,7 @@ from pathlib import Path
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:
@@ -142,6 +152,8 @@ class Settings:
obsigate_mcp_token: str = ""
obsigate_mcp_tool: str = ""
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
briefing_include_standup: bool = False
rules_raw: str = ""
@property
def auth_enabled(self) -> bool:
@@ -184,4 +196,6 @@ def get_settings() -> Settings:
"KANBANTE_OBSIGATE_MCP_ARGS",
'{"title": "{title}", "content": "{content}"}',
),
briefing_include_standup=_env_bool("KANBANTE_BRIEFING_INCLUDE_STANDUP", False),
rules_raw=os.getenv("KANBANTE_RULES", ""),
)
+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.11.0"},
"clientInfo": {"name": "kanbante", "version": "0.13.0"},
},
req_id=1,
)
+119
View File
@@ -0,0 +1,119 @@
"""Forecasts (roadmap #7): honest ETAs from the event journal.
Throughput = issues closed per week, counted from board_events.
Projections are deliberately given as a RANGE (best/worst weekly
rate over the last 4 complete weeks) rather than a fake-precise
date, and are withheld entirely when the journal has no closures
to learn from — "not enough history" is an answer, too.
"""
from datetime import date, datetime, timedelta
MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
MONTHS_FR_SHORT = [
"janv.", "févr.", "mars", "avr.", "mai", "juin",
"juil.", "août", "sept.", "oct.", "nov.", "déc.",
]
def french_date(d: date, short: bool = False) -> str:
months = MONTHS_FR_SHORT if short else MONTHS_FR
return f"{d.day} {months[d.month - 1]}"
def _event_date(event: dict) -> date | None:
raw = str(event.get("created_at") or "")
try:
return datetime.strptime(raw[:10], "%Y-%m-%d").date()
except ValueError:
return None
def weekly_closures(events: list[dict], weeks: int = 8,
today: date | None = None) -> list[dict]:
"""Closure counts per ISO week, oldest first, current week last."""
today = today or date.today()
current_monday = today - timedelta(days=today.weekday())
buckets: list[dict] = []
for i in range(weeks - 1, -1, -1):
start = current_monday - timedelta(weeks=i)
buckets.append({"start": start, "count": 0})
by_start = {b["start"]: b for b in buckets}
for event in events:
if event.get("event_type") != "closed":
continue
day = _event_date(event)
if day is None:
continue
monday = day - timedelta(days=day.weekday())
if monday in by_start:
by_start[monday]["count"] += 1
return buckets
def throughput_stats(weekly: list[dict], last: int = 4) -> dict | None:
"""avg/min/max closures per week over the last COMPLETE weeks.
The current (partial) week is excluded: on Monday morning it
would crush the average for no reason. Returns None when the
complete weeks contain no closure at all.
"""
complete = weekly[:-1][-last:] if len(weekly) > 1 else []
if not complete:
return None
counts = [b["count"] for b in complete]
if sum(counts) == 0:
return None
return {
"avg": sum(counts) / len(counts),
"min": min(counts),
"max": max(counts),
"weeks": len(counts),
}
def estimate(remaining: int, weekly: list[dict],
today: date | None = None) -> dict | None:
"""Projected completion for `remaining` items at the real pace.
Returns {"done": True} when nothing remains, None when there is
no throughput to project from, else dates: central (avg rate),
earliest (best week), latest (worst nonzero week, if any).
"""
today = today or date.today()
if remaining <= 0:
return {"done": True}
stats = throughput_stats(weekly)
if stats is None:
return None
def at_rate(rate: float) -> date:
days = remaining / rate * 7
return today + timedelta(days=max(1, round(days)))
result = {
"done": False,
"avg_per_week": round(stats["avg"], 1),
"central": at_rate(stats["avg"]),
"earliest": at_rate(stats["max"]) if stats["max"] > 0 else None,
"latest": at_rate(stats["min"]) if stats["min"] > 0 else None,
}
return result
def eta_label(est: dict | None, done_text: str = "terminé") -> str | None:
"""One-line French label for banners and cards (or None)."""
if est is None:
return None
if est.get("done"):
return f"✅ {done_text}"
label = f"à ce rythme ≈ {french_date(est['central'], short=True)}"
if est.get("earliest") and est.get("latest"):
label += (
f" (fourchette {french_date(est['earliest'], short=True)}"
f" – {french_date(est['latest'], short=True)})"
)
return label
+19
View File
@@ -341,6 +341,25 @@ class GiteaClient:
self._cache_clear()
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(
self, owner: str, repo: str, issue_number: int, logins: list[str]
) -> dict:
+753 -3
View File
@@ -35,23 +35,35 @@ from starlette.middleware.base import BaseHTTPMiddleware
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.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.models import (
KANBAN_COLUMNS,
add_pick,
column_entry_times,
get_board_state,
get_events,
get_kv,
get_metrics,
get_notes,
get_picks,
get_stats,
init_db,
log_event,
move_issue,
ping_db,
reconcile_board_state,
remove_pick,
set_column_order,
set_kv,
set_notes,
@@ -143,6 +155,7 @@ async def lifespan(app: FastAPI):
)
scheduler = asyncio.create_task(_briefing_scheduler())
digest_task = asyncio.create_task(_digest_scheduler())
rules_task = asyncio.create_task(_rules_scheduler())
capture_task = None
if settings.capture_ntfy_topic and settings.ntfy_url:
async def _capture_create(title: str, body: str) -> None:
@@ -166,6 +179,7 @@ async def lifespan(app: FastAPI):
yield
scheduler.cancel()
digest_task.cancel()
rules_task.cancel()
if capture_task is not None:
capture_task.cancel()
await gitea.aclose()
@@ -457,6 +471,10 @@ async def dashboard(request: Request):
"""Main dashboard — all projects at a glance."""
repos = await gitea.search_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(
request,
"dashboard.html",
@@ -494,6 +512,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
milestones: list[dict] = []
selected_milestone = None
milestone_progress = None
milestone_forecast = None
display_issues = open_issues
try:
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"),
"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)
columns = _merge_issues_with_board(display_issues, board_state)
await _annotate_code(owner, repo, columns)
labels: dict[str, dict] = {}
for issue in open_issues:
@@ -561,6 +597,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
milestones=milestones,
selected_milestone=selected_milestone,
milestone_progress=milestone_progress,
milestone_forecast=milestone_forecast,
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(
owner, repo, all_items, milestone
)
await _annotate_code(owner, repo, columns)
settings = get_settings()
ages = _card_ages(full_name, display_issues, board_state)
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)
async def focus(request: Request):
"""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_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"),
"today_picks": today_picks,
"carry_over": carry_over,
"focus_total": sum(len(g["cards"]) for g in focus_groups),
"focus_groups": [
{
@@ -1051,6 +1622,66 @@ async def _briefing_data() -> dict:
"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)
@@ -1133,6 +1764,60 @@ def _apply_external_transition(
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")
async def gitea_webhook(request: Request):
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
@@ -1170,6 +1855,11 @@ async def gitea_webhook(request: Request):
handled = _apply_external_transition(
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()
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}")
async def api_issue_detail(owner: str, repo: str, number: int):
"""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")
async def api_metrics(repo: str = "", window: int = 30):
"""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 ───────────────────────────────────────
+37 -1
View File
@@ -79,6 +79,13 @@ def init_db() -> None:
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
@@ -239,6 +246,35 @@ def reconcile_board_state(
return changed
def get_picks(pick_date: str) -> list[dict]:
"""The day's focus picks (issue #5), in pinning order."""
with get_db() as conn:
rows = conn.execute(
"SELECT repo_full_name, issue_number FROM today_picks "
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
(pick_date,),
).fetchall()
return [dict(row) for row in rows]
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
(pick_date, repo_full_name, issue_number),
)
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? "
"AND repo_full_name = ? AND issue_number = ?",
(pick_date, repo_full_name, issue_number),
)
def get_kv(key: str) -> str | None:
with get_db() as conn:
row = conn.execute(
@@ -258,7 +294,7 @@ def set_kv(key: str, value: str) -> None:
# ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
def log_event(
+111
View File
@@ -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
+79
View File
@@ -0,0 +1,79 @@
"""Sprint story (roadmap #4): the milestone's tale, written by the journal.
Nobody writes the end-of-milestone report by hand. Kanbante's
event journal already knows what shipped, how long it took and
what dragged — this module just turns that into clean markdown,
ready to archive in ObsiGate.
"""
def format_sprint_markdown(
repo: str,
milestone_title: str,
due_on: str | None,
period_label: str,
delivered: list[dict],
carried: list[dict],
counts: dict,
avg_cycle_days: float | None,
) -> str:
lines = [f"# 📖 Récit de sprint — {repo} · {milestone_title}", ""]
lines.append(f"*Période : {period_label}*")
if due_on:
lines.append(f"*Échéance du milestone : {due_on[:10]}*")
lines.append("")
lines.append(f"## ✅ Livré ({len(delivered)})")
if delivered:
for item in delivered:
cycle = (
f" — ⏱️ {item['cycle_days']} j de cycle"
if item.get("cycle_days") is not None
else ""
)
lines.append(
f"- [#{item['number']}]({item.get('html_url', '')}) "
f"{item.get('title', '')}{cycle}".rstrip()
)
else:
lines.append("- Rien n'a été fermé dans ce milestone pour l'instant.")
lines.append("")
slowest = sorted(
(d for d in delivered if d.get("cycle_days") is not None),
key=lambda d: -d["cycle_days"],
)[:3]
if slowest:
lines.append("## 🐌 Les plus lentes")
for item in slowest:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— {item['cycle_days']} j de cycle"
)
lines.append("")
lines.append(f"## ↩️ Reporté ({len(carried)})")
if carried:
for item in carried:
lines.append(
f"- #{item['number']} {item.get('title', '')} "
f"— encore en *{item.get('column_label', 'backlog')}*"
)
else:
lines.append("- Rien : tout le milestone est livré. 🎉")
lines.append("")
lines.append("## 📈 En chiffres")
lines.append(f"- Issues du milestone : {len(delivered) + len(carried)}")
lines.append(f"- Fermées : {len(delivered)}")
if avg_cycle_days is not None:
lines.append(f"- Temps de cycle moyen : {avg_cycle_days} j")
lines.append(f"- Déplacements de cartes : {counts.get('moved', 0)}")
lines.append(f"- Commentaires : {counts.get('commented', 0)}")
lines.append(f"- Réouvertures : {counts.get('reopened', 0)}")
lines.append("")
lines.append(
"*Récit généré par Kanbante à partir de son journal d'événements "
"et des issues Gitea du milestone.*"
)
return "\n".join(lines)
+4
View File
@@ -19,6 +19,9 @@
{% endfor %}
</div>
{% endif %}
{% if issue._code %}
<div class="card-code">⑂{% if issue._code.branches %} 🌿 {{ issue._code.branches }}{% endif %}{% if issue._code.commits %} · {{ issue._code.commits }} commit{{ 's' if issue._code.commits > 1 }}{% endif %}{% if issue._code.pr %} · PR #{{ issue._code.pr.number }} {{ issue._code.pr.label }}{% endif %}</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
@@ -29,5 +32,6 @@
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
{% endif %}
<button type="button" class="pin-btn" title="Épingler au Focus du jour (/today)">📌</button>
</div>
</div>
+4
View File
@@ -16,12 +16,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/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>
+9
View File
@@ -20,11 +20,14 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/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>
{% if repo_info.html_url %}
@@ -51,6 +54,10 @@
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
{% if milestone_forecast %}
<span class="milestone-forecast">🔮 {{ milestone_forecast }}</span>
{% endif %}
<a class="milestone-sprint" href="/sprint/{{ full_name }}?milestone={{ milestone_param|urlencode }}">📖 Récit de sprint</a>
</div>
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
@@ -158,6 +165,7 @@
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
<div id="detail-label-toggles" class="label-toggles"></div>
</div>
<div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div>
<h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div>
@@ -176,6 +184,7 @@
{% endif %}
</footer>
<script src="/static/js/board-live.js" defer></script>
<script src="/static/js/today.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
+3
View File
@@ -17,11 +17,14 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/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>
+4
View File
@@ -13,12 +13,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 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 active">⚡ 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>
+7
View File
@@ -16,12 +16,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/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>
@@ -86,6 +90,9 @@
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
{% if repo.forecast_label %}
<span class="forecast-line" title="Prévision d'après le débit réel du journal (fermetures par semaine)">🔮 {{ repo.forecast_label }}</span>
{% endif %}
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
+2
View File
@@ -13,9 +13,11 @@
<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 active">🧠 Digest</a>
+3
View File
@@ -19,9 +19,12 @@
<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>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
+77
View File
@@ -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>
+3
View File
@@ -16,11 +16,14 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill active">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/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>
+97
View File
@@ -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>
+3
View File
@@ -16,11 +16,14 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
<a href="/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>
+75
View File
@@ -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>
+55
View File
@@ -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>
+105
View File
@@ -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>
+4
View File
@@ -13,12 +13,16 @@
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.11.0"
version = "0.13.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+45
View File
@@ -1106,3 +1106,48 @@ footer {
border: 1px solid rgba(240, 180, 41, 0.55); border-radius: 99px;
padding: 6px 16px; font-size: 0.95rem; z-index: 51;
}
/* ── Focus du jour (0.12.0, #5) ── */
.pin-btn {
background: transparent; border: none; cursor: pointer;
font-size: 0.82rem; padding: 0 2px; margin-left: auto; opacity: 0.45;
}
.pin-btn:hover { opacity: 1; transform: scale(1.15); }
.today-main { max-width: 880px; margin: 0 auto; padding: 28px 16px 44px; }
.today-title { font-size: 1.9rem; margin: 0 0 4px; text-transform: capitalize; }
.today-sub { color: var(--text-3); margin: 0 0 22px; }
.today-picks { display: grid; gap: 14px; margin-bottom: 20px; }
.today-card {
background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52);
border-left: 5px solid var(--accent, #58a6ff); border-radius: 14px; padding: 20px 24px;
}
.today-card.done { border-left-color: var(--good, #3fb950); opacity: 0.85; }
.today-card-top { display: flex; justify-content: space-between; color: var(--text-3); font-size: 0.85rem; }
.today-card h2 { margin: 8px 0 12px; font-size: 1.3rem; line-height: 1.35; }
.today-card-actions { display: flex; gap: 16px; align-items: center; }
.today-card-actions a { color: var(--accent, #58a6ff); text-decoration: none; font-size: 0.9rem; }
.today-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
.today-list li { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
.today-list em { color: var(--text-3); font-style: normal; font-size: 0.85rem; }
/* ── Prévisions + Récit (0.12.0, #7 #4) ── */
.forecast-flex { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
.sparkline { width: 168px; height: 48px; }
.sparkline rect { fill: var(--accent, #58a6ff); opacity: 0.85; }
.forecast-nums p { margin: 4px 0; }
.forecast-milestones { margin: 12px 0 0; padding-left: 18px; line-height: 1.8; }
.forecast-line { color: var(--accent, #58a6ff); }
.milestone-forecast { color: var(--accent, #58a6ff); font-weight: 600; }
.milestone-sprint { color: var(--text-1); text-decoration: none; border: 1px solid var(--border, #22304a); border-radius: 8px; padding: 2px 10px; }
.milestone-sprint:hover { border-color: var(--accent, #58a6ff); }
.sprint-render { line-height: 1.65; }
.sprint-render h1 { font-size: 1.5rem; }
.sprint-render h2 { font-size: 1.15rem; margin-top: 22px; }
.sprint-render a { color: var(--accent, #58a6ff); }
/* ── Code lié (0.13.0, #1) ── */
.card-code { font-size: 0.78rem; color: var(--accent, #58a6ff); margin-top: 5px; }
.detail-code { margin: 10px 0; }
.code-line { padding: 3px 0; font-size: 0.9rem; color: var(--text-2, #b6c2d2); }
.code-line a { color: var(--accent, #58a6ff); text-decoration: none; }
.code-line code { background: var(--surface-3, #1d2942); border-radius: 4px; padding: 0 5px; }
+28
View File
@@ -369,12 +369,40 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number);
} catch (err) {
document.getElementById('detail-title').textContent = 'Erreur de chargement';
showToast('❌ Détail indisponible : ' + err.message, true);
}
}
async function loadCodeLinks(repoFull, number) {
const box = document.getElementById('detail-code');
if (!box) return;
box.innerHTML = '';
const [owner, repo] = repoFull.split('/', 2);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}/code`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) return;
const parts = [];
if ((data.branches || []).length) {
parts.push('🌿 Branches : ' + data.branches.map(b => `<code>${b}</code>`).join(', '));
}
for (const pr of data.pulls || []) {
const state = pr.merged ? 'fusionnée' : (pr.state === 'open' ? 'ouverte' : 'fermée');
parts.push(`🔀 <a href="${pr.html_url}" target="_blank" rel="noopener">PR #${pr.number}</a> ${pr.title} — ${state}`);
}
for (const c of (data.commits || []).slice(0, 8)) {
parts.push(`⑂ <a href="${c.html_url}" target="_blank" rel="noopener"><code>${c.sha}</code></a> ${c.message}${c.date ? ' · ' + c.date : ''}`);
}
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) {
document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state');
+4
View File
@@ -13,6 +13,10 @@
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
{ icon: '⚙️', label: 'Règles d\'automatisation', href: '/rules' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
+48
View File
@@ -0,0 +1,48 @@
/**
* Kanbante — Focus of the day (issue #5): pin/unpin from /today and,
* via a delegated handler, the 📌 button on board cards.
*/
(function () {
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
function post(url, repo, number) {
var data = new FormData();
data.append('repo_full_name', repo);
data.append('issue_number', number);
data.append('csrf_token', csrf);
return fetch(url, { method: 'POST', body: data }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (body) {
if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status));
return body;
});
});
}
// Capture phase: a 📌 click on a board card must be swallowed
// before board.js sees it (it would open the detail drawer).
document.addEventListener('click', function (e) {
var pinBtn = e.target.closest('[data-pin]');
var unpinBtn = e.target.closest('[data-unpin]');
var cardPin = e.target.closest('.pin-btn');
if (pinBtn) {
post('/api/today/pin', pinBtn.getAttribute('data-repo'), pinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (unpinBtn) {
post('/api/today/unpin', unpinBtn.getAttribute('data-repo'), unpinBtn.getAttribute('data-number'))
.then(function () { location.reload(); })
.catch(function (err) { alert(err.message); });
} else if (cardPin) {
// Board card 📌: never open the detail drawer for this click.
e.stopPropagation();
e.preventDefault();
var card = cardPin.closest('.kanban-card');
post('/api/today/pin', card.getAttribute('data-repo'), card.getAttribute('data-issue'))
.then(function () {
cardPin.textContent = '📌✓';
cardPin.title = 'Épinglée au Focus du jour (/today)';
})
.catch(function (err) { alert(err.message); });
}
}, true);
})();
+43
View File
@@ -223,6 +223,49 @@ class FakeGitea:
for label in removed:
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"):
return [
{
+202
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
"""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):
+176
View File
@@ -0,0 +1,176 @@
"""Palier 9 (0.12.0): Focus du jour (#5), Prévisions (#7), Récit (#4)."""
from datetime import date, timedelta
from app import forecast as fc
from app import models
from tests.conftest import csrf_from
def _ev(day: date, kind: str = "closed") -> dict:
return {"event_type": kind, "created_at": day.strftime("%Y-%m-%d 10:00:00")}
# ── Prévisions : fonctions pures ──────────────────────────────────
def test_weekly_closures_buckets():
today = date(2026, 10, 14) # a Wednesday
monday = today - timedelta(days=2)
events = [
_ev(monday), _ev(monday + timedelta(days=1)), # this week: 2
_ev(monday - timedelta(days=7)), # last week: 1
_ev(monday - timedelta(days=30), "moved"), # not a closure
_ev(monday - timedelta(days=90)), # too old
]
weekly = fc.weekly_closures(events, weeks=8, today=today)
assert len(weekly) == 8
assert weekly[-1]["count"] == 2
assert weekly[-2]["count"] == 1
assert sum(b["count"] for b in weekly) == 3
def test_throughput_excludes_current_week_and_needs_history():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = []
# 4 complete weeks: 2, 4, 0, 6 closures; current week: 99 (ignored)
for weeks_ago, n in ((1, 2), (2, 4), (3, 0), (4, 6)):
for k in range(n):
events.append(_ev(monday - timedelta(weeks=weeks_ago) + timedelta(days=k % 5)))
for k in range(9):
events.append(_ev(monday + timedelta(days=k % 2)))
weekly = fc.weekly_closures(events, weeks=8, today=today)
stats = fc.throughput_stats(weekly)
assert stats == {"avg": 3.0, "min": 0, "max": 6, "weeks": 4}
assert fc.throughput_stats(fc.weekly_closures([], today=today)) is None
def test_estimate_dates_and_labels():
today = date(2026, 10, 14)
monday = today - timedelta(days=2)
events = [_ev(monday - timedelta(weeks=w)) for w in (1, 2, 3, 4) for _ in range(2)]
weekly = fc.weekly_closures(events, weeks=8, today=today)
est = fc.estimate(6, weekly, today=today)
assert est["done"] is False
assert est["avg_per_week"] == 2.0
assert est["central"] == today + timedelta(days=21)
# min == max == 2/week -> no spread: latest==earliest==central
assert est["earliest"] == est["central"] == est["latest"]
label = fc.eta_label(est)
assert "à ce rythme ≈" in label
assert fc.estimate(0, weekly, today=today) == {"done": True}
assert fc.estimate(5, [], today=today) is None
assert fc.french_date(date(2026, 11, 3)) == "3 novembre"
def test_metrics_include_weekly_closures(client, fake_gitea):
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/metrics", params={"repo": "Projets/demo"})
assert r.status_code == 200
weekly = r.json()["metrics"]["weekly_closures"]
assert len(weekly) == 8
assert weekly[-1] == 1 # the closure we just logged is this week
def test_forecast_page_and_milestone_banner(client, fake_gitea):
r = client.get("/forecast")
assert r.status_code == 200
assert "Prévisions" in r.text
assert "Projets/demo" in r.text
assert "Sprint 1" in r.text
board = client.get("/board/Projets/demo", params={"milestone": "Sprint 1"})
assert board.status_code == 200
assert "🔮" in board.text
assert "historique pour prévoir" in board.text # empty journal
assert "/sprint/Projets/demo" in board.text
# ── Focus du jour ─────────────────────────────────────────────────
def _pin(client, token, number, repo="Projets/demo"):
return client.post("/api/today/pin", data={
"repo_full_name": repo, "issue_number": number, "csrf_token": token,
})
def test_today_pin_unpin_flow(client, fake_gitea):
token = csrf_from(client)
r = _pin(client, token, 1)
assert r.status_code == 200 and r.json()["count"] == 1
r = _pin(client, token, 1) # idempotent
assert r.json().get("already") is True
page = client.get("/today")
assert page.status_code == 200
assert "Tâche un" in page.text
r = client.post("/api/today/unpin", data={
"repo_full_name": "Projets/demo", "issue_number": 1, "csrf_token": token,
})
assert r.status_code == 200
assert models.get_picks(models_now()) == []
def models_now() -> str:
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d")
def test_today_pin_cap_three(client, fake_gitea):
token = csrf_from(client)
today = models_now()
for n in (11, 12, 13):
models.add_pick(today, "Projets/demo", n)
r = _pin(client, token, 1)
assert r.status_code == 400
assert "3 cartes max" in r.json()["error"]
def test_briefing_includes_today_picks(client, fake_gitea):
models.add_pick(models_now(), "Projets/demo", 5)
r = client.get("/api/briefing")
assert r.status_code == 200
picks = r.json()["briefing"]["today_picks"]
assert [p["number"] for p in picks] == [5]
assert picks[0]["title"] == "Tâche focus"
def test_today_page_shows_candidates(client, fake_gitea):
token = csrf_from(client)
r = client.post("/api/move", data={
"repo_full_name": "Projets/demo", "issue_number": 1,
"from_column": "backlog", "to_column": "in_progress",
"to_order": "1", "csrf_token": token,
})
assert r.status_code == 200
page = client.get("/today")
assert page.status_code == 200
assert "Candidates" in page.text
assert "Tâche un" in page.text
# ── Récit de sprint ───────────────────────────────────────────────
def test_sprint_markdown_from_fake_milestone(client, fake_gitea):
models.log_event("Projets/demo", 3, "moved", to_column="in_progress")
models.log_event("Projets/demo", 3, "closed", to_column="done")
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert r.status_code == 200
md = r.json()["markdown"]
assert "# 📖 Récit de sprint — Projets/demo · Sprint 1" in md
assert "## ✅ Livré (1)" in md
assert "Ancienne tâche" in md
assert "## ↩️ Reporté (1)" in md
assert "Tâche un" in md
page = client.get("/sprint/Projets/demo", params={"milestone": "Sprint 1"})
assert page.status_code == 200
assert "Copier le markdown" in page.text
def test_sprint_unknown_milestone_404(client, fake_gitea):
r = client.get("/api/sprint/Projets/demo", params={"milestone": "Nope"})
assert r.status_code == 404
page = client.get("/sprint/Projets/demo")
assert page.status_code == 200
assert "Sprint 1" in page.text # picker lists the milestones