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).
This commit is contained in:
Spark
2026-10-26 15:06:02 +00:00
parent a92353f8ef
commit 8ad3a86e60
6 changed files with 378 additions and 52 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## 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 ## 0.35.0 — 2026-10-26
### Ajouté — jalons en écriture depuis la page Projet (#26) ### Ajouté — jalons en écriture depuis la page Projet (#26)
+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.35.0" APP_VERSION = "0.36.0"
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
+203 -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
@@ -5402,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,
@@ -5459,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
@@ -5473,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"}
@@ -5485,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:
@@ -5538,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,
@@ -6236,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 = [
{ {
@@ -6337,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()
@@ -6466,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}")
@@ -6477,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],
} }
@@ -6486,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).
""" """
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.35.0" version = "0.36.0"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+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()
+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 ────────────────────────