Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f54b1b79 | ||
|
|
80fc260a40 | ||
|
|
8ad3a86e60 | ||
|
|
a92353f8ef |
+111
@@ -1,5 +1,116 @@
|
||||
# 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)
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
||||
|
||||
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:
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
+336
-43
@@ -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
|
||||
@@ -3147,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,
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
async def _briefing_data() -> dict:
|
||||
@@ -5269,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,
|
||||
@@ -5326,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
|
||||
@@ -5340,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"}
|
||||
@@ -5352,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:
|
||||
@@ -5405,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,
|
||||
@@ -6103,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 = [
|
||||
{
|
||||
@@ -6204,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()
|
||||
@@ -6333,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}")
|
||||
|
||||
|
||||
@@ -6344,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],
|
||||
}
|
||||
|
||||
@@ -6353,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).
|
||||
"""
|
||||
|
||||
@@ -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())">
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.34.0"
|
||||
version = "0.37.1"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1738,3 +1738,15 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user