feat(0.36.0): outil MCP move_issue — déplacement de carte par le service d'agents
CI / test (push) Failing after 1m27s
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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 ────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user