diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df56a6..747325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # 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 ### Ajouté — jalons en écriture depuis la page Projet (#26) diff --git a/app/config.py b/app/config.py index d92ebd6..71bf4b6 100644 --- a/app/config.py +++ b/app/config.py @@ -177,7 +177,7 @@ from urllib.parse import urlparse 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: diff --git a/app/main.py b/app/main.py index f3fbcfd..216e0bf 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -5402,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, @@ -5459,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 @@ -5473,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"} @@ -5485,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: @@ -5538,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, @@ -6236,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 = [ { @@ -6337,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() @@ -6466,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}") @@ -6477,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], } @@ -6486,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). """ diff --git a/pyproject.toml b/pyproject.toml index f25cd0e..a96d8d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.35.0" +version = "0.36.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_mcp_writes.py b/tests/test_mcp_writes.py index c487957..b9badff 100644 --- a/tests/test_mcp_writes.py +++ b/tests/test_mcp_writes.py @@ -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() diff --git a/tests/test_palier3.py b/tests/test_palier3.py index 3dbfd0f..20ba998 100644 --- a/tests/test_palier3.py +++ b/tests/test_palier3.py @@ -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 ────────────────────────