Compare commits

..
4 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
16 changed files with 922 additions and 60 deletions
+111
View File
@@ -1,5 +1,116 @@
# Changelog # 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 ## 0.34.0 — 2026-10-26
### Ajouté — vue « En vol partout » /inflight (issue #24) ### Ajouté — vue « En vol partout » /inflight (issue #24)
+1 -1
View File
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.34.0" APP_VERSION = "0.37.1"
def _env_bool(name: str, default: bool) -> bool: 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]: async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides """Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth).""" on Gitea stars — the forge stays the source of truth)."""
+336 -43
View File
@@ -23,6 +23,7 @@ import logging
import re import re
import secrets import secrets
import time import time
import unicodedata
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -3147,7 +3148,10 @@ async def project_page(request: Request, owner: str, repo: str):
est = forecast_service.estimate(opened, weekly) est = forecast_service.estimate(opened, weekly)
milestones.append( milestones.append(
{ {
"id": m.get("id"),
"title": m.get("title", ""), "title": m.get("title", ""),
"description": m.get("description") or "",
"due_iso": due_date.isoformat() if due_date else "",
"total": total, "total": total,
"closed": closed, "closed": closed,
"open": opened, "open": opened,
@@ -4373,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 ─────────────────────────────────────────────── # ── Briefing ───────────────────────────────────────────────
async def _briefing_data() -> dict: async def _briefing_data() -> dict:
@@ -5269,38 +5403,34 @@ async def activity(request: Request):
# ── Routes: API ──────────────────────────────────────────── # ── Routes: API ────────────────────────────────────────────
@app.post("/api/move") async def _do_move_card(
async def api_move( repo_full_name: str,
request: Request, issue_number: int,
repo_full_name: str = Form(...), to_column: str,
issue_number: int = Form(...), *,
to_column: str = Form(...), from_column: str = "",
from_column: str = Form(""), to_order: str = "",
to_order: str = Form(""), from_order: str = "",
from_order: str = Form(""), client,
csrf_token: str = Form(""), conn_id: int,
): ws_id: int,
"""Move an issue to a new column and persist the resulting order. 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 Board local (placement + ordres), journal des événements,
the issue in Gitea and moving out of "done" reopens it. A failed write-back Gitea (fermeture en entrant dans « done »,
write-back is reported in the response (gitea_sync="failed") instead réouverture en en sortant — échecs rapportés, jamais
of failing the move itself. 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): owner, repo = _split_full_name(repo_full_name)
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
)
move_issue( move_issue(
repo_full_name, issue_number, to_column, repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id, workspace_id=ws_id, connection_id=conn_id,
@@ -5326,7 +5456,7 @@ async def api_move(
if from_column != to_column: if from_column != to_column:
log_event( log_event(
repo_full_name, issue_number, "moved", 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, workspace_id=ws_id, connection_id=conn_id,
) )
# Émission Lot 2 (journal du service d'agents) : le titre # Émission Lot 2 (journal du service d'agents) : le titre
@@ -5340,7 +5470,7 @@ async def api_move(
titre_carte = "" titre_carte = ""
_emettre_changement_colonne( _emettre_changement_colonne(
repo_full_name, issue_number, from_column or None, to_column, 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"} result: dict = {"ok": True, "gitea_sync": "skipped"}
@@ -5352,14 +5482,14 @@ async def api_move(
result["gitea_sync"] = "closed" result["gitea_sync"] = "closed"
log_event( log_event(
repo_full_name, issue_number, "closed", to_column="done", 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": elif from_column == "done":
await client.set_issue_state(owner, repo, issue_number, "open") await client.set_issue_state(owner, repo, issue_number, "open")
result["gitea_sync"] = "reopened" result["gitea_sync"] = "reopened"
log_event( log_event(
repo_full_name, issue_number, "reopened", 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, workspace_id=ws_id, connection_id=conn_id,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -5405,6 +5535,46 @@ async def api_move(
return result 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") @app.post("/api/issues")
async def api_create_issue( async def api_create_issue(
request: Request, request: Request,
@@ -6103,9 +6273,10 @@ async def api_toggle_issue_label(
# ── MCP server (tools over JSON-RPC) ─────────────────────── # ── MCP server (tools over JSON-RPC) ───────────────────────
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue, # Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
# add_comment) depuis la 0.26.0, gardées par la même authentification # add_comment) depuis la 0.26.0, move_issue depuis la 0.36.0,
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le # gardées par la même authentification que tout /mcp (session,
# middleware : un jeton n'ouvre jamais l'interface web). # Basic ou jeton d'API kbt_ — voir le middleware : un jeton
# n'ouvre jamais l'interface web).
MCP_TOOLS = [ MCP_TOOLS = [
{ {
@@ -6204,15 +6375,77 @@ MCP_TOOLS = [
"required": ["owner", "repo", "number", "body"], "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: async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one MCP tool and return text content. """Execute one MCP tool and return text content.
Reads hit the forge's read API; the two write tools Reads hit the forge's read API; the write tools reuse the
(create_issue, add_comment) reuse the existing creation / existing services (creation via _do_capture, comment via the
comment services — never a reimplemented forge call. forge client, move via _do_move_card) — never a reimplemented
forge call.
""" """
if name == "list_projects": if name == "list_projects":
repos = await gitea.search_repos() repos = await gitea.search_repos()
@@ -6333,6 +6566,65 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
}, },
ensure_ascii=False, 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}") raise ValueError(f"Unknown tool: {name}")
@@ -6344,7 +6636,7 @@ async def mcp_info():
"version": APP_VERSION, "version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)", "transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "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], "tools": [t["name"] for t in MCP_TOOLS],
} }
@@ -6353,8 +6645,9 @@ async def mcp_info():
async def mcp_endpoint(request: Request): async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping. """Minimal MCP server: initialize / tools/list / tools/call / ping.
Reads since 0.5.0; since 0.26.0, two minimal write tools Reads since 0.5.0; since 0.26.0, minimal write tools
(create_issue, add_comment) for the agent service, guarded by (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 the same /mcp authentication as the reads (session, Basic or
kbt_ API token — a token never unlocks the web UI). kbt_ API token — a token never unlocks the web UI).
""" """
+2
View File
@@ -64,6 +64,7 @@
<label class="dash-tool">Tri <label class="dash-tool">Tri
<select id="dash-sort"> <select id="dash-sort">
<option value="default">Par défaut</option> <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="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option> <option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option> <option value="open">Issues ouvertes ↓</option>
@@ -85,6 +86,7 @@
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}" data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}" data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}" 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-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}" data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())"> x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
+33 -2
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title> <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="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> </head>
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
@@ -51,11 +53,39 @@
{% else %} {% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div> <div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %} {% 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> </div>
{% endfor %} {% endfor %}
{% else %} {% 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 %} {% 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>
<section class="proj-panel"> <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> <footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %} {% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script> <script src="/static/js/palette.js" defer></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.34.0" version = "0.37.1"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+12
View File
@@ -1738,3 +1738,15 @@ body.sidebar-collapsed .ws-switch { display: none; }
.inflight-total { font-weight: 700; margin-right: 4px; } .inflight-total { font-weight: 700; margin-right: 4px; }
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; } .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; } .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) { function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase(); if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10)); 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 === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || ''; if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien return null; // 'default' : ordre serveur, on ne touche à rien
@@ -52,6 +53,13 @@
if (isNaN(tb)) return -1; if (isNaN(tb)) return -1;
return tb - ta; 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;
if (ka > kb) return mode === 'name' ? 1 : -1; if (ka > kb) return mode === 'name' ? 1 : -1;
return 0; return 0;
+33
View File
@@ -43,6 +43,7 @@
var etatConnu = false; var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null; var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() { function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } 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">' + ' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' + ' <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-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>'; '</div>';
document.body.appendChild(root); document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs"); msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -276,6 +278,7 @@
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre"); titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions"); tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir); root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion); root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -330,6 +333,35 @@
titreEl.title = titreEl.textContent; 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() { function verifierIdentite() {
if (etatConnu) return; if (etatConnu) return;
if (!API) { if (!API) {
@@ -812,6 +844,7 @@
root.classList.add("open"); root.classList.add("open");
appliquerTheme(); appliquerTheme();
verifierIdentite(); verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300); setTimeout(function () { inputEl.focus(); }, 300);
} }
function fermer() { if (root) root.classList.remove("open"); } function fermer() { if (root) root.classList.remove("open"); }
+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: else:
self.starred.discard(f"{owner}/{repo}") self.starred.discard(f"{owner}/{repo}")
async def get_milestones(self, owner, repo, state="open"): @property
return [ def _milestones(self):
return self.__dict__.setdefault(
"milestones_store",
{ {
"id": 1, "title": "Sprint 1", "Projets/demo": [
"due_on": "2026-10-20T00:00:00Z", {
"open_issues": 1, "closed_issues": 1, "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=""): async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1 self._next_number += 1
issue = _issue(self._next_number, title) 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 Le service d'agents doit pouvoir proposer des cartes, commenter
via Kanbante, sans passer par l'interface web. Les deux outils et déplacer des cartes via Kanbante, sans passer par l'interface
réutilisent les services existants (création via _do_capture → web. Les trois outils réutilisent les services existants
client de forge, commentaire via client.create_comment) et sont (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 : gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web. 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) ───────────────────── # ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture() @pytest.fixture()
+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 == [ assert names == [
"list_projects", "get_board", "get_issue", "get_focus", "list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics", "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 assert r.status_code == 200
# 0.26.0 : écritures minimales ajoutées — le serveur n'est # 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement. # 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()["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 ──────────────────────── # ── Board : gestion des étiquettes ────────────────────────