Compare commits

...
6 Commits
Author SHA1 Message Date
Spark 53f54b1b79 feat(0.37.1): version du service d'agents visible dans le panneau Spark (mode sidebar) — fusion #64
CI / test (push) Failing after 2s
2026-10-26 16:49:25 +00:00
Spark 80fc260a40 feat(0.37.0): tri dashboard « En attente de revue » + sens des tris numériques corrigé
CI / test (push) Successful in 2m27s
- Nouveau critère 🔍 En attente de revue ↓ (compteur de la
  colonne review exposé en data-review sur les cartes) ;
  s'applique à toutes les grilles, Épinglés en tête,
  préférence mémorisée comme les autres tris.
- Correctif attrapé au contrôle navigateur réel : le tri
  « Issues ouvertes ↓ » historique triait croissant — le
  comparateur inversait les clés numériques. Modes open et
  review en ka - kb : plus grand compteur en premier.
  Vérifié sur données réelles (Épinglés : 196, 95, 32, 5, 4).
- 299 tests verts (3 nouveaux, palier 25).
2026-10-26 11:19:53 -04:00
Spark 8ad3a86e60 feat(0.36.0): outil MCP move_issue — déplacement de carte par le service d'agents
CI / test (push) Failing after 1m27s
Le serveur MCP n'exposait en écriture que create_issue et
add_comment : un déplacement approuvé côté service d'agents
(kanbante.move_card, vague H3) aurait échoué faute d'outil.

- move_issue (owner, repo, number, column) réutilise le service
  de déplacement de l'interface (_do_move_card, extrait de
  POST /api/move sans changement de comportement) : board
  local, journal source « mcp », miroir d'étiquette kanban:*
  remplacée, autres étiquettes préservées.
- Colonne acceptée par clé, par étiquette de forge ou par
  libellé français affiché (définitions de l'espace).
- Garde doctrine : la cible done (« Terminé ») est refusée
  avant toute écriture — les agents s'arrêtent en Révision.
- Erreurs propres : colonne inconnue (liste des valides),
  dépôt/issue inconnu, pull request refusée.
- 296 tests verts (5 nouveaux + épingles de la liste d'outils
  mises à jour).
2026-10-26 15:06:02 +00:00
Spark a92353f8ef feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s
- Clients : create_milestone / update_milestone (cache
  invalidé) ; GitHub hérite des mêmes endpoints.
- API Kanbante : création, modification (titre/échéance,
  échéance vidée = retirée), fermeture ; CSRF, validation
  de date avant toute écriture, erreurs forge en 502.
- Page Projet : formulaire 'Nouveau jalon' + édition/
  fermeture par jalon (project.js versionné).
- Échéance à midi UTC : à minuit, le fuseau de l'instance
  Gitea faisait reculer la date affichée d'un jour (réel).
- 291 tests verts (5 nouveaux, palier 24) ; cycle réel
  complet sur Projets/kanbante (jalons smoke #37/#38 créés,
  dates vérifiées, fermés).
2026-10-26 10:53:29 -04:00
Spark 8c889878ee feat(0.34.0): vue « En vol partout » /inflight (issue #24)
CI / test (push) Successful in 2m24s
- L'encours (colonnes en vol de l'espace) tous projets
  actifs, regroupé PAR colonne, trié par âge décroissant
  dans la colonne (pipeline _card_ages du board).
- En-tête : total + compteurs par colonne + limite WIP
  globale (somme des projets qui en définissent).
- Entrées Pilotage (menu latéral) et palette Ctrl+K ; SSE
  /api/stream via live.js comme les autres vues.
- Réutilise _collect_focus_groups : aucun appel forge en
  plus. 286 tests verts (5 nouveaux, palier 23) ; vérifié
  en Chromium réel (356 cartes en vol sur les vraies
  données, dont la carte #24 elle-même).
2026-10-26 10:48:45 -04:00
Spark dfb4e595ff fix(test): horodatage capturé une fois (flake CI, écart d'1 s)
CI / test (push) Successful in 2m23s
test_column_entry_times_ignores_comments évaluait _days_ago(3)
à la création puis à l'assertion : une bascule de seconde
entre les deux faisait échouer le run CI 2333 alors que le
run jumeau (tag v0.33.0) et la suite locale passaient.
2026-10-26 10:23:06 -04:00
21 changed files with 1200 additions and 62 deletions
+139
View File
@@ -1,5 +1,144 @@
# Changelog
## 0.37.1 — 2026-10-26
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
une valeur codée en dur ; service injoignable ou panneau non configuré
= rien d'affiché, aucune erreur visible.
## 0.37.0 — 2026-10-26
### Ajouté — tri « En attente de revue » sur le dashboard
Demande de Bruno : pouvoir ordonner les projets — section
**Épinglés** en tête — par ce qui attend sa validation.
- Nouveau critère dans le sélecteur de tri du dashboard,
en première place après « Par défaut » : **🔍 En attente
de revue ↓** — les projets s'ordonnent par le nombre de
cartes dans la colonne de validation (clé `review` de
l'espace), décroissant.
- Le compteur était déjà calculé pour les chips de
progression des cartes : il est maintenant exposé en
`data-review` sur chaque carte projet et consommé par le
moteur de tri partagé — le critère s'applique à **toutes
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
avec ou sans regroupement par propriétaire, et la
préférence est mémorisée comme les autres tris.
- Les critères existants (activité Gitea, activité kanban,
issues ouvertes, nom) sont inchangés.
### Corrigé — sens des tris numériques du dashboard
Le contrôle en navigateur réel a révélé que le tri
« Issues ouvertes ↓ » triait en réalité **croissant**
depuis son introduction : le comparateur partagé inversait
les clés numériques. Les modes numériques (`open` et le
nouveau `review`) trient maintenant en soustraction de
clés — le plus grand compteur en premier, comme l'annonce
le « ↓ ». Vérifié sur les données réelles : Épinglés
ordonnés 196, 95, 32, 5, 4 en attente de revue.
## 0.36.0 — 2026-10-26
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
Le service d'agents sait proposer et appliquer des déplacements
de cartes par approbation (`kanbante.move_card`), mais le serveur
MCP de Kanbante n'exposait en écriture que `create_issue` et
`add_comment` : un déplacement approuvé aurait échoué faute
d'outil. Le trou est fermé.
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
`number`, `column`) : déplace une carte vers une autre colonne
en réutilisant le service de déplacement de l'interface
(`_do_move_card`, extrait de `POST /api/move` sans changement
de comportement) — board local, journal des événements
(source « mcp »), émission vers le journal du service
d'agents et miroir d'étiquette.
- **Étiquette kanban remplacée, les autres préservées** : le
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
pose celle de la colonne cible ; les étiquettes libres de
l'issue (ex. « bug ») ne sont jamais touchées.
- **Colonne souple** : la clé (`review`), l'étiquette de forge
(`kanban:review`, alias d'espace compris) et le libellé
français affiché (« Révision », « À faire », « En cours »…)
sont acceptés ; les libellés viennent de la définition des
colonnes de l'espace, pas d'une table figée.
- **Garde doctrine** : la cible `done` (« Terminé ») est
**refusée avant toute écriture**, avec une erreur explicite —
Terminé appartient à Bruno dans son interface, les agents
s'arrêtent en Révision.
- **Erreurs propres** : colonne inconnue (avec la liste des
colonnes valides), dépôt ou issue inconnu, pull request
refusée comme n'étant pas une carte.
- Même garde que les autres outils /mcp (session, Basic ou
jeton d'API `kbt_`).
## 0.35.0 — 2026-10-26
### Ajouté — jalons en écriture depuis la page Projet (#26)
Seconde et dernière carte du backlog de la feuille de
route « Gestion de projets » : les jalons n'étaient que lus
dans Kanbante, ils s'écrivent maintenant — la forge reste
la source de vérité, rien n'est stocké localement.
- **Créer** un jalon (titre, description et échéance
optionnelles) depuis la page Projet, formulaire
« ➕ Nouveau jalon ».
- **Modifier** titre et échéance d'un jalon ouvert
(formulaire déplié par jalon ; échéance vidée = échéance
retirée, par un `null` explicite côté forge).
- **Fermer** un jalon, avec confirmation (ses issues
restent ouvertes).
- Écritures dans la forge via son API (`POST/PATCH
.../milestones`), CSRF vérifié, échéance invalide
refusée proprement **avant** toute écriture ; le board
filtré et le bandeau de progression reflètent le nouveau
jalon au chargement suivant, sans redémarrage.
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
dans le fuseau de son instance — à minuit UTC, l'échéance
affichée reculait d'un jour (attrapé au smoke réel).
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
réassigner les issues entre jalons.
## 0.34.0 — 2026-10-26
### Ajouté — vue « En vol partout » /inflight (issue #24)
Dernière carte du backlog de la feuille de route « Gestion
de projets », première du classement big picture (pilotage
quotidien des agents avant gestion de projet).
- **Page /inflight** : tout l'encours (colonnes en vol de
l'espace — En cours, QA, Révision par défaut) **tous
projets actifs confondus, regroupé par colonne** — là où
/focus groupe par projet et /my par assignation.
- Chaque carte : projet (lien board), titre, étiquettes,
assigné, et **âge dans la colonne** (badge ⏳, teinté
selon les seuils d'ancienneté) — les groupes sont **triés
par âge décroissant** : les plus vieilles cartes, le vrai
signal, en premier.
- En-tête : total en vol + compteurs par colonne ;
**limite WIP globale** affichée quand des projets en
définissent (somme par colonne).
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
palette Ctrl+K. Rafraîchissement doux par le SSE
`/api/stream` comme les autres vues (fermer une issue la
retire au rechargement).
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
`_card_ages`) : aucun appel forge supplémentaire.
## 0.33.0 — 2026-10-26
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
+1 -1
View File
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.33.0"
APP_VERSION = "0.37.1"
def _env_bool(name: str, default: bool) -> bool:
+53
View File
@@ -477,6 +477,59 @@ class GiteaClient:
),
)
async def create_milestone(
self,
owner: str,
repo: str,
title: str,
description: str = "",
due_on: str | None = None,
) -> dict:
"""Create a milestone on the forge (issue #26)."""
payload: dict = {"title": title, "description": description}
if due_on:
payload["due_on"] = due_on
r = await self.client.post(
f"/repos/{owner}/{repo}/milestones", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def update_milestone(
self,
owner: str,
repo: str,
milestone_id: int,
title: str | None = None,
description: str | None = None,
due_on: str | None = None,
clear_due: bool = False,
state: str | None = None,
) -> dict:
"""Patch a milestone (title, due date, state) — #26.
due_on=None + clear_due=False leaves the date alone;
clear_due=True sends an explicit null (Gitea clears it).
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if clear_due:
payload["due_on"] = None
elif due_on:
payload["due_on"] = due_on
if state is not None:
payload["state"] = state
r = await self.client.patch(
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth)."""
+419 -43
View File
@@ -23,6 +23,7 @@ import logging
import re
import secrets
import time
import unicodedata
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -2118,6 +2119,7 @@ _NAV_BY_TEMPLATE = {
"capture.html": "capture",
"digest.html": "digest",
"forecast.html": "forecast",
"inflight.html": "inflight",
"rules.html": "rules",
"health.html": "health",
"health_project.html": "health",
@@ -3146,7 +3148,10 @@ async def project_page(request: Request, owner: str, repo: str):
est = forecast_service.estimate(opened, weekly)
milestones.append(
{
"id": m.get("id"),
"title": m.get("title", ""),
"description": m.get("description") or "",
"due_iso": due_date.isoformat() if due_date else "",
"total": total,
"closed": closed,
"open": opened,
@@ -4030,6 +4035,88 @@ async def focus(request: Request):
)
async def _collect_inflight(ws_repos, ws_id: int) -> dict:
"""The work in flight, grouped BY COLUMN (issue #24).
Reuses the Focus pipeline (per-project groups of cards in
the workspace's in-flight columns), then pivots: one group
per column, cards sorted by age in column, oldest first —
the oldest cards are the signal. Ages come from the same
_card_ages pipeline as the board.
"""
per_repo = await _collect_focus_groups(ws_repos, ws_id)
columns: dict[str, list[dict]] = {
key: [] for key in bcols.focus_keys(ws_id)
}
for group in per_repo:
full_name = group["full_name"]
cid = group.get("conn_id", registry.default_id)
open_issues = [c["issue"] for c in group["cards"]]
board = get_board_state(
full_name, workspace_id=ws_id, connection_id=cid
)
ages = _card_ages(
full_name, open_issues, board,
workspace_id=ws_id, connection_id=cid,
)
for card in group["cards"]:
age = ages.get(card["issue"]["number"]) or {}
columns.setdefault(card["column"], []).append(
{
"issue": card["issue"],
"repo": full_name,
"column": card["column"],
"age_days": age.get("days", 0),
"age_since": age.get("since", ""),
}
)
groups = []
total = 0
for key in bcols.keys(ws_id):
cards = columns.get(key)
if not cards:
continue
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
groups.append({"column": key, "cards": cards})
total += len(cards)
for key, cards in columns.items():
if key not in {g["column"] for g in groups} and cards:
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
groups.append({"column": key, "cards": cards})
total += len(cards)
# Limites WIP globales lisibles : somme, par colonne, des
# limites définies dans les projets (0.33.0 : lecture seule).
settings = get_settings()
wip_globals: dict[str, int] = {}
for _repo, limits in (settings.wip_limits or {}).items():
for col, limit in (limits or {}).items():
try:
wip_globals[col] = wip_globals.get(col, 0) + int(limit)
except (TypeError, ValueError):
continue
wip_globals = {c: v for c, v in wip_globals.items() if v > 0}
return {"groups": groups, "total": total, "wip_globals": wip_globals}
@app.get("/inflight", response_class=HTMLResponse)
async def inflight(request: Request):
"""Everything in flight, all projects, grouped by column (#24)."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
ws_repos, _ws = await _workspace_repos(request)
data = await _collect_inflight(ws_repos, ws_id)
return render(
request,
"inflight.html",
groups=data["groups"],
total=data["total"],
wip_globals=data["wip_globals"],
column_labels=bcols.names(ws_id),
column_colors=bcols.colors(ws_id),
aging_days=get_settings().aging_days,
)
def _group_issue_results(issues: list[dict]) -> list[dict]:
"""Group search results by repo, annotated with the board column."""
by_repo: dict[str, list[dict]] = {}
@@ -4290,6 +4377,136 @@ async def my_issues(request: Request):
)
def _milestone_due_iso(due: str) -> tuple[str | None, bool]:
"""Validate a YYYY-MM-DD form date -> (ISO instant, ok)."""
due = (due or "").strip()
if not due:
return None, True
try:
parsed = datetime.strptime(due, "%Y-%m-%d")
except ValueError:
return None, False
# Midi UTC, pas minuit : Gitea renvoie l'échéance dans le
# fuseau de son instance (Toronto = UTC-5) — à minuit UTC,
# la date affichée reculait d'un jour (attrapé en réel).
return parsed.strftime("%Y-%m-%dT12:00:00Z"), True
@app.post("/api/repos/{owner}/{repo}/milestones")
async def api_milestone_create(
request: Request,
owner: str,
repo: str,
title: str = Form(""),
description: str = Form(""),
due_on: str = Form(""),
csrf_token: str = Form(""),
):
"""Create a milestone on the forge (issue #26)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
title = title.strip()
if not title or len(title) > 255:
return JSONResponse(
{"ok": False, "error": "Titre requis (255 caractères max)"},
status_code=400,
)
due_iso, ok = _milestone_due_iso(due_on)
if not ok:
return JSONResponse(
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
status_code=400,
)
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
milestone = await client.create_milestone(
owner, repo, title, description.strip(), due_iso
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return JSONResponse(
{"ok": True, "milestone": {
"id": milestone.get("id"),
"title": milestone.get("title"),
"due_on": milestone.get("due_on"),
}},
status_code=201,
)
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/update")
async def api_milestone_update(
request: Request,
owner: str,
repo: str,
milestone_id: int,
title: str = Form(""),
due_on: str = Form(""),
csrf_token: str = Form(""),
):
"""Edit a milestone's title / due date (issue #26).
An empty due date CLEARS the milestone's deadline.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
title = title.strip()
if not title or len(title) > 255:
return JSONResponse(
{"ok": False, "error": "Titre requis (255 caractères max)"},
status_code=400,
)
due_iso, ok = _milestone_due_iso(due_on)
if not ok:
return JSONResponse(
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
status_code=400,
)
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
milestone = await client.update_milestone(
owner, repo, milestone_id, title=title,
due_on=due_iso, clear_due=due_iso is None,
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return {"ok": True, "milestone": {
"id": milestone.get("id"),
"title": milestone.get("title"),
"due_on": milestone.get("due_on"),
}}
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/close")
async def api_milestone_close(
request: Request,
owner: str,
repo: str,
milestone_id: int,
csrf_token: str = Form(""),
):
"""Close a milestone on the forge (issue #26)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
await client.update_milestone(
owner, repo, milestone_id, state="closed"
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return {"ok": True}
# ── Briefing ───────────────────────────────────────────────
async def _briefing_data() -> dict:
@@ -5186,38 +5403,34 @@ async def activity(request: Request):
# ── Routes: API ────────────────────────────────────────────
@app.post("/api/move")
async def api_move(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
to_column: str = Form(...),
from_column: str = Form(""),
to_order: str = Form(""),
from_order: str = Form(""),
csrf_token: str = Form(""),
):
"""Move an issue to a new column and persist the resulting order.
async def _do_move_card(
repo_full_name: str,
issue_number: int,
to_column: str,
*,
from_column: str = "",
to_order: str = "",
from_order: str = "",
client,
conn_id: int,
ws_id: int,
source: str = "ui",
) -> dict:
"""Déplacement effectif d'une carte, une fois la portée résolue.
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
the issue in Gitea and moving out of "done" reopens it. A failed
write-back is reported in the response (gitea_sync="failed") instead
of failing the move itself.
Board local (placement + ordres), journal des événements,
write-back Gitea (fermeture en entrant dans « done »,
réouverture en en sortant — échecs rapportés, jamais
bloquants) et miroir de l'étiquette kanban:* de la colonne
(les autres étiquettes de l'issue sont préservées).
Partagé par POST /api/move (interface, source « ui ») et par
l'outil MCP move_issue (source « mcp ») — même patron que
_do_capture pour la création. L'outil MCP refuse la colonne
« done » en amont : y entrer ferme l'issue, et Terminé
appartient à Bruno dans son interface.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
if to_column not in bcols.keys(ws_id):
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
owner, repo = _split_full_name(repo_full_name)
move_issue(
repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id,
@@ -5243,7 +5456,7 @@ async def api_move(
if from_column != to_column:
log_event(
repo_full_name, issue_number, "moved",
from_column or None, to_column,
from_column or None, to_column, source=source,
workspace_id=ws_id, connection_id=conn_id,
)
# Émission Lot 2 (journal du service d'agents) : le titre
@@ -5257,7 +5470,7 @@ async def api_move(
titre_carte = ""
_emettre_changement_colonne(
repo_full_name, issue_number, from_column or None, to_column,
"ui", title=titre_carte,
source, title=titre_carte,
)
result: dict = {"ok": True, "gitea_sync": "skipped"}
@@ -5269,14 +5482,14 @@ async def api_move(
result["gitea_sync"] = "closed"
log_event(
repo_full_name, issue_number, "closed", to_column="done",
workspace_id=ws_id, connection_id=conn_id,
source=source, workspace_id=ws_id, connection_id=conn_id,
)
elif from_column == "done":
await client.set_issue_state(owner, repo, issue_number, "open")
result["gitea_sync"] = "reopened"
log_event(
repo_full_name, issue_number, "reopened",
from_column="done", to_column=to_column,
from_column="done", to_column=to_column, source=source,
workspace_id=ws_id, connection_id=conn_id,
)
except httpx.HTTPError as exc:
@@ -5322,6 +5535,46 @@ async def api_move(
return result
@app.post("/api/move")
async def api_move(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
to_column: str = Form(...),
from_column: str = Form(""),
to_order: str = Form(""),
from_order: str = Form(""),
csrf_token: str = Form(""),
):
"""Move an issue to a new column and persist the resulting order.
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
the issue in Gitea and moving out of "done" reopens it. A failed
write-back is reported in the response (gitea_sync="failed") instead
of failing the move itself.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
if to_column not in bcols.keys(ws_id):
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
return await _do_move_card(
repo_full_name, issue_number, to_column,
from_column=from_column, to_order=to_order,
from_order=from_order, client=client, conn_id=conn_id,
ws_id=ws_id, source="ui",
)
@app.post("/api/issues")
async def api_create_issue(
request: Request,
@@ -6020,9 +6273,10 @@ async def api_toggle_issue_label(
# ── MCP server (tools over JSON-RPC) ───────────────────────
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
# add_comment) depuis la 0.26.0, gardées par la même authentification
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le
# middleware : un jeton n'ouvre jamais l'interface web).
# add_comment) depuis la 0.26.0, move_issue depuis la 0.36.0,
# gardées par la même authentification que tout /mcp (session,
# Basic ou jeton d'API kbt_ — voir le middleware : un jeton
# n'ouvre jamais l'interface web).
MCP_TOOLS = [
{
@@ -6121,15 +6375,77 @@ MCP_TOOLS = [
"required": ["owner", "repo", "number", "body"],
},
},
{
"name": "move_issue",
"description": "Move a card (issue) to another board column. "
"column accepts the column key (backlog, todo, in_progress, "
"qa, review), the forge label (kanban:review) or the French "
"display label (« À faire », « En cours », « Révision »…). "
"The kanban:* label on the issue is replaced; its other "
"labels are preserved. Moving to done (« Terminé ») is "
"REFUSED: that column belongs to the human owner — agents "
"stop at review. Write tool — same /mcp authentication as "
"the reads (kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
"column": {"type": "string"},
},
"required": ["owner", "repo", "number", "column"],
},
},
]
def _plier_libelle_colonne(valeur: str) -> str:
"""Plie un libellé de colonne pour comparaison tolérante :
minuscules, accents retirés, emoji et ponctuation éliminés,
séparateurs unifiés en espaces (« in_progress »,
« in-progress » et « In progress » se rejoignent)."""
texte = unicodedata.normalize("NFKD", valeur or "")
texte = "".join(c for c in texte if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]+", " ", texte.lower()).strip()
def _normaliser_colonne_mcp(brut: str, ws_id: int) -> str:
"""Résout la colonne visée par l'outil MCP move_issue.
Accepte la clé (« review »), l'étiquette de forge
(« kanban:review », alias d'espace compris) et le libellé
français affiché (« Révision », « À faire »…) — les noms
d'affichage viennent de la définition des colonnes de
l'espace, pas d'une table figée. Lève ValueError, avec la
liste des colonnes valides, si rien ne correspond.
"""
candidats: dict[str, str] = {}
for colonne in bcols.get_columns(ws_id):
cle = colonne["key"]
candidats[_plier_libelle_colonne(cle)] = cle
candidats[_plier_libelle_colonne(colonne.get("name") or "")] = cle
if colonne.get("label"):
candidats[_plier_libelle_colonne(colonne["label"])] = cle
for etiquette, cle in bcols.label_lookup(ws_id).items():
candidats[_plier_libelle_colonne(etiquette)] = cle
candidats["to do"] = "todo"
visee = _plier_libelle_colonne(brut)
if visee in candidats:
return candidats[visee]
valides = ", ".join(bcols.keys(ws_id))
raise ValueError(
f"Colonne inconnue : « {brut} ». Colonnes valides : {valides}."
)
async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one MCP tool and return text content.
Reads hit the forge's read API; the two write tools
(create_issue, add_comment) reuse the existing creation /
comment services — never a reimplemented forge call.
Reads hit the forge's read API; the write tools reuse the
existing services (creation via _do_capture, comment via the
forge client, move via _do_move_card) — never a reimplemented
forge call.
"""
if name == "list_projects":
repos = await gitea.search_repos()
@@ -6250,6 +6566,65 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
},
ensure_ascii=False,
)
if name == "move_issue":
owner, repo = args["owner"], args["repo"]
number = int(args["number"])
full_name = f"{owner}/{repo}"
client, conn_id = await _client_for_repo(full_name)
ws_id = next(
(
ws["id"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
),
1,
)
target = _normaliser_colonne_mcp(str(args.get("column") or ""), ws_id)
if target == "done":
# Garde doctrine : Terminé appartient à Bruno dans son
# interface — refus avant toute écriture (entrer dans
# « done » fermerait l'issue côté forge).
raise ValueError(
"Colonne refusée : « Terminé » (done) appartient à "
"Bruno — les agents s'arrêtent en Révision (review)."
)
try:
issue = await client.get_issue(owner, repo, number)
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 404:
raise ValueError(
f"Dépôt ou issue inconnu : {full_name}#{number}"
) from exc
raise
if issue.get("pull_request"):
raise ValueError(
f"{full_name}#{number} est une pull request, pas une carte."
)
placement: dict[int, str] = {}
for col_name, cards in get_board_state(full_name).items():
for card in cards:
placement[card["issue_number"]] = col_name
from_column = placement.get(number)
if from_column is None:
from_column = (
"done" if issue.get("state") == "closed" else "backlog"
)
result = await _do_move_card(
full_name, number, target,
from_column=from_column,
client=client, conn_id=conn_id, ws_id=ws_id, source="mcp",
)
return json.dumps(
{
"repo_full_name": full_name,
"number": number,
"title": issue.get("title", ""),
"from_column": from_column,
"column": target,
**result,
},
ensure_ascii=False,
)
raise ValueError(f"Unknown tool: {name}")
@@ -6261,7 +6636,7 @@ async def mcp_info():
"version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False,
"write_tools": ["create_issue", "add_comment"],
"write_tools": ["create_issue", "add_comment", "move_issue"],
"tools": [t["name"] for t in MCP_TOOLS],
}
@@ -6270,8 +6645,9 @@ async def mcp_info():
async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
Reads since 0.5.0; since 0.26.0, two minimal write tools
(create_issue, add_comment) for the agent service, guarded by
Reads since 0.5.0; since 0.26.0, minimal write tools
(create_issue, add_comment — move_issue since 0.36.0) for the
agent service, guarded by
the same /mcp authentication as the reads (session, Basic or
kbt_ API token — a token never unlocks the web UI).
"""
+1
View File
@@ -20,6 +20,7 @@
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("inflight", "/inflight", "🛩️", "En vol"),
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
+2
View File
@@ -64,6 +64,7 @@
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="review">🔍 En attente de revue ↓</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
@@ -85,6 +86,7 @@
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>En vol partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
<div class="inflight-summary">
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
{% for group in groups %}
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
</span>
{% endfor %}
</div>
{% if not groups %}
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2>
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
{{ column_labels[group.column] }}
<span class="badge secondary">{{ group.cards|length }}</span>
{% if wip_globals.get(group.column) %}
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
{% endif %}
</h2>
<div class="focus-cards">
{% for card in group.cards %}
<div class="kanban-card focus-card">
<div class="card-id">
#{{ card.issue.number }}
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
{% if card.issue.html_url %}
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ card.issue.title }}</div>
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if card.issue.assignee %}
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
{% endif %}
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</main>
<footer>
Kanbante v{{ version }} · en vol partout
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+33 -2
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="repo-full-name" content="{{ full_name }}">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
@@ -51,11 +53,39 @@
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
{% if ms.id %}
<details class="ms-edit">
<summary>✏️ Modifier / fermer</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
<span class="ms-hint">vide = retirer l'échéance</span></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
</div>
</form>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
</form>
</details>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
{% endif %}
<details class="ms-edit ms-new">
<summary>➕ Nouveau jalon</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
<label>Échéance <input type="date" name="due_on"></label>
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
</div>
</form>
</details>
</section>
<section class="proj-panel">
@@ -124,6 +154,7 @@
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.33.0"
version = "0.37.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+18
View File
@@ -1732,3 +1732,21 @@ body.sidebar-collapsed .ws-switch { display: none; }
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
/* ── En vol partout (0.34.0, issue #24) ── */
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
.inflight-total { font-weight: 700; margin-right: 4px; }
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
.ms-edit { margin-top: 8px; }
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
.ms-edit summary::-webkit-details-marker { display: none; }
.ms-edit summary:hover { text-decoration: underline; }
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
.ms-form-actions { display: flex; gap: 8px; }
+8
View File
@@ -32,6 +32,7 @@
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
@@ -52,6 +53,13 @@
if (isNaN(tb)) return -1;
return tb - ta;
}
// Modes numériques (open, review) : les clés sont
// des compteurs niés (-n) — ka - kb donne donc le
// plus grand compteur en premier, comme l'annonce
// le « ↓ » des options. (Avant 0.37.0, la
// comparaison générique ci-dessous inversait ces
// deux tris : ils ressortaient croissants.)
if (mode === 'open' || mode === 'review') return ka - kb;
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
+33
View File
@@ -43,6 +43,7 @@
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -268,6 +269,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -276,6 +278,7 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -330,6 +333,35 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
if (!API) {
@@ -812,6 +844,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+1
View File
@@ -13,6 +13,7 @@
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '🛩️', label: 'En vol partout — encours tous projets, par colonne', href: '/inflight' },
{ 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' },
+27
View File
@@ -0,0 +1,27 @@
// Page Projet — écritures de jalons (0.35.0, issue #26).
// Les formulaires data-ms-form sont interceptés : POST vers
// l'API Kanbante (qui écrit dans la forge), puis rechargement
// pour refléter l'état réel.
(() => {
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
document.querySelectorAll('form[data-ms-form]').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const confirmMsg = form.dataset.confirm;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const fd = new FormData(form);
fd.append('csrf_token', csrf);
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
try {
const r = await fetch(form.dataset.action, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
window.location.reload();
} catch (err) {
if (btn) btn.disabled = false;
window.alert('❌ ' + err.message);
}
});
});
})();
+55 -6
View File
@@ -359,15 +359,64 @@ class FakeGitea:
else:
self.starred.discard(f"{owner}/{repo}")
async def get_milestones(self, owner, repo, state="open"):
return [
@property
def _milestones(self):
return self.__dict__.setdefault(
"milestones_store",
{
"id": 1, "title": "Sprint 1",
"due_on": "2026-10-20T00:00:00Z",
"open_issues": 1, "closed_issues": 1,
}
"Projets/demo": [
{
"id": 1, "title": "Sprint 1",
"description": "",
"due_on": "2026-10-20T00:00:00Z",
"state": "open",
"open_issues": 1, "closed_issues": 1,
}
]
},
)
async def get_milestones(self, owner, repo, state="open"):
items = self._milestones.get(f"{owner}/{repo}", [])
return [
dict(m) for m in items
if state == "all" or m.get("state", "open") == state
]
async def create_milestone(
self, owner, repo, title, description="", due_on=None
):
store = self._milestones.setdefault(f"{owner}/{repo}", [])
milestone = {
"id": 100 + len(store),
"title": title,
"description": description,
"due_on": due_on,
"state": "open",
"open_issues": 0, "closed_issues": 0,
}
store.append(milestone)
return dict(milestone)
async def update_milestone(
self, owner, repo, milestone_id, title=None, description=None,
due_on=None, clear_due=False, state=None,
):
for m in self._milestones.get(f"{owner}/{repo}", []):
if m["id"] == milestone_id:
if title is not None:
m["title"] = title
if description is not None:
m["description"] = description
if clear_due:
m["due_on"] = None
elif due_on:
m["due_on"] = due_on
if state is not None:
m["state"] = state
return dict(m)
raise AssertionError("jalon introuvable dans le faux")
async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1
issue = _issue(self._next_number, title)
+132 -5
View File
@@ -1,9 +1,11 @@
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
move_issue (0.36.0).
Le service d'agents doit pouvoir proposer des cartes et commenter
via Kanbante, sans passer par l'interface web. Les deux outils
réutilisent les services existants (création via _do_capture →
client de forge, commentaire via client.create_comment) et sont
Le service d'agents doit pouvoir proposer des cartes, commenter
et déplacer des cartes via Kanbante, sans passer par l'interface
web. Les trois outils réutilisent les services existants
(création via _do_capture → client de forge, commentaire via
client.create_comment, déplacement via _do_move_card) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
@@ -145,6 +147,131 @@ def test_mcp_write_tools_are_listed(client):
)
# ── Déplacement de carte (0.36.0) ──────────────────────────
def _seed_carte_todo(fake_gitea, number=1):
"""Place la carte dans « À faire », étiquette kanban:todo
miroir posée (la carte garde son étiquette « bug »)."""
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
issue = next(
i for i in fake_gitea.issues["Projets/demo"]
if i["number"] == number
)
issue["labels"].append(etiquette)
models.move_issue("Projets/demo", number, "todo")
return issue
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
issue = _seed_carte_todo(fake_gitea)
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["from_column"] == "todo"
assert payload["column"] == "review"
assert payload["label_sync"] == "ok"
# Étiquette kanban remplacée, les autres préservées.
noms = [l["name"] for l in issue["labels"]]
assert "kanban:review" in noms
assert "kanban:todo" not in noms
assert "bug" in noms
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
# Le board suit le déplacement (lecture MCP relue).
board = json.loads(_result_text(_call(
client, "get_board", {"owner": "Projets", "repo": "demo"}
))[1])
assert any(c["number"] == 1 for c in board["review"])
assert not any(c["number"] == 1 for c in board["todo"])
# Journal : déplacement tracé avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
moved = [e for e in events if e["event_type"] == "moved"]
assert moved and moved[0]["source"] == "mcp"
assert moved[0]["from_column"] == "todo"
assert moved[0]["to_column"] == "review"
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle, cle in (
("En cours", "in_progress"),
("Révision", "review"),
("kanban:qa", "qa"),
("À faire", "todo"),
):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is False, (libelle, text)
assert json.loads(text)["column"] == cle
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle in ("done", "Terminé"):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Terminé" in text
# Aucune écriture : ni étiquette, ni état, ni journal.
assert fake_gitea.label_ops == []
assert fake_gitea.state_changes == []
events = models.get_events(repo_full_name="Projets/demo", limit=10)
assert not [e for e in events if e["event_type"] == "moved"]
def test_mcp_move_issue_errors(client, fake_gitea):
# Colonne inconnue : erreur propre, avec les colonnes valides.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "nimporte",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Colonnes valides" in text and "review" in text
# Issue inconnue, puis dépôt inconnu.
for arguments in (
{"owner": "Projets", "repo": "demo", "number": 999,
"column": "review"},
{"owner": "Projets", "repo": "inexistant", "number": 1,
"column": "review"},
):
result, text = _result_text(_call(client, "move_issue", arguments))
assert result["isError"] is True
assert "inconnu" in text
# Une pull request n'est pas une carte.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 2,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "pull request" in text
assert fake_gitea.label_ops == []
def test_mcp_move_issue_is_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["move_issue"]["inputSchema"]["required"] == [
"owner", "repo", "number", "column",
]
info = client.get("/mcp").json()
assert "move_issue" in info["write_tools"]
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
+6 -2
View File
@@ -97,11 +97,15 @@ def test_metrics_counts_and_cycle_time():
def test_column_entry_times_ignores_comments():
# Capturer l'horodatage UNE fois : _days_ago() réévalué à
# l'assertion basculait d'une seconde une fois sur ~N
# (échec CI 2333 du 2026-10-26, run jumeau vert).
entry = _days_ago(3)
models.log_event("a/b", 1, "moved", to_column="todo",
created_at=_days_ago(3))
created_at=entry)
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
times = models.column_entry_times("a/b")
assert times[1] == _days_ago(3)
assert times[1] == entry
# ── Panneau détail ─────────────────────────────────────────
+72
View File
@@ -0,0 +1,72 @@
"""Palier 23 (0.34.0, issue #24): vue « En vol partout » —
l'encours tous projets, regroupé par colonne, plus vieilles
cartes d'abord."""
from datetime import datetime, timedelta, timezone
from app import models
def _days_ago(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
def test_inflight_groups_by_column_with_project(client):
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/inflight")
assert r.status_code == 200
assert "2 cartes en vol" in r.text
assert "Tâche focus" in r.text
assert "Tâche un" in r.text
# Le projet est indiqué sur la carte.
assert "Projets/demo" in r.text
# Groupes par colonne, pas par projet.
assert "🚧 En cours" in r.text
assert "🔍 Révision" in r.text
# L'âge dans la colonne est affiché.
assert "dans la colonne" in r.text
def test_inflight_excludes_backlog_and_todo(client):
models.move_issue("Projets/demo", 5, "todo")
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
assert "Rien en vol" in r.text
def test_inflight_oldest_first(client):
# Deux cartes en cours : entrée il y a 10 j et il y a 2 j.
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "in_progress")
models.log_event(
"Projets/demo", 5, "moved", to_column="in_progress",
created_at=_days_ago(10),
)
models.log_event(
"Projets/demo", 1, "moved", to_column="in_progress",
created_at=_days_ago(2),
)
r = client.get("/inflight")
assert r.status_code == 200
assert r.text.index("Tâche focus") < r.text.index("Tâche un")
def test_inflight_closed_issue_disappears(client, fake_gitea):
models.move_issue("Projets/demo", 5, "in_progress")
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
issue["state"] = "closed"
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
def test_inflight_nav_and_page_chrome(client):
r = client.get("/inflight")
assert 'href="/inflight"' in r.text # menu latéral Pilotage
assert "En vol partout" in r.text
+77
View File
@@ -0,0 +1,77 @@
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
from tests.conftest import csrf_from
def test_milestone_create_via_api(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Version 2.0",
"description": "Le gros morceau",
"due_on": "2027-01-31",
"csrf_token": token,
},
)
assert r.status_code == 201
payload = r.json()
assert payload["ok"] is True
assert payload["milestone"]["title"] == "Version 2.0"
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
titles = [
m["title"] for m in fake_gitea._milestones["Projets/demo"]
]
assert "Version 2.0" in titles
def test_milestone_create_rejects_bad_date(client, fake_gitea):
token = csrf_from(client)
avant = len(fake_gitea._milestones["Projets/demo"])
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Jalon invalide",
"due_on": "31/01/2027",
"csrf_token": token,
},
)
assert r.status_code == 400
# Rien d'écrit côté forge.
assert len(fake_gitea._milestones["Projets/demo"]) == avant
def test_milestone_update_title_and_clear_due(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/update",
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
)
assert r.status_code == 200
sprint = fake_gitea._milestones["Projets/demo"][0]
assert sprint["title"] == "Sprint 1 — prolongé"
assert sprint["due_on"] is None # échéance retirée
def test_milestone_close(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/close",
data={"csrf_token": token},
)
assert r.status_code == 200
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
page = client.get("/project/Projets/demo")
assert "Version" in page.text # la page répond toujours
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
def test_project_page_has_milestone_management(client):
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "Nouveau jalon" in page.text
assert "Modifier / fermer" in page.text
assert 'name="due_on"' in page.text
assert "/static/js/project.js?v=" in page.text
+36
View File
@@ -0,0 +1,36 @@
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
les projets (section Épinglés incluse) s'ordonnent par le nombre
de cartes dans la colonne de validation."""
from app import models
def test_dashboard_sort_offers_review_mode(client):
r = client.get("/")
assert r.status_code == 200
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
def test_dashboard_cards_carry_review_count(client):
# Aucune carte en révision au départ.
r = client.get("/")
assert 'data-review="0"' in r.text
# Une carte passe en Révision : le compteur de SA carte monte.
models.move_issue("Projets/demo", 5, "review")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/")
assert 'data-review="2"' in r.text
def test_dashboard_js_knows_review_mode(client):
# Le moteur de tri partagé est servi tel quel : le mode
# 'review' doit y figurer (tri décroissant sur data-review).
r = client.get("/static/js/dashboard.js")
assert r.status_code == 200
assert "mode === 'review'" in r.text
assert "data-review" in r.text
# Le sens : les modes numeriques trient en ka - kb (cles
# niees = plus grand compteur d\'abord). Sans cette
# branche, le comparateur generique inversait le tri :
# bug attrape en navigateur reel sur le tri historique.
assert "return ka - kb" in r.text
+5 -2
View File
@@ -121,7 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment",
"create_issue", "add_comment", "move_issue",
]
@@ -167,8 +167,11 @@ def test_mcp_info_endpoint(client):
assert r.status_code == 200
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement.
# 0.36.0 : move_issue s'ajoute aux écritures déclarées.
assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
assert r.json()["write_tools"] == [
"create_issue", "add_comment", "move_issue",
]
# ── Board : gestion des étiquettes ────────────────────────