Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e | ||
|
|
53f54b1b79 | ||
|
|
80fc260a40 | ||
|
|
8ad3a86e60 |
@@ -1,5 +1,87 @@
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22).
|
||||
|
||||
Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici
|
||||
sans variante :
|
||||
|
||||
- Kanbante ne stocke QUE des références ``coffre:<slug>/<nom>``, jamais une
|
||||
valeur distribuée ;
|
||||
- à chaque usage, le SERVEUR Kanbante appelle
|
||||
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
|
||||
Kanbante (distinct du jeton MCP de notes et de la session SSO) et un
|
||||
contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ;
|
||||
- aucun cache de valeur, aucune persistance, aucun log de la valeur ;
|
||||
- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli
|
||||
sur une ancienne valeur ;
|
||||
- ObsiGate indisponible -> échec propre, sans continuer avec une valeur
|
||||
en cache (il ne doit pas y en avoir).
|
||||
|
||||
Configuration (variables d'environnement, jamais versionnées) :
|
||||
- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net``
|
||||
- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur
|
||||
« kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti
|
||||
côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé).
|
||||
- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10).
|
||||
|
||||
Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de
|
||||
la bibliothèque standard, pour rester testable avec un faux endpoint
|
||||
(``httpx.MockTransport``) sans instance ObsiGate réelle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("kanbante.coffre")
|
||||
|
||||
PREFIXE_REFERENCE = "coffre:"
|
||||
CHEMIN_DISTRIBUER = "/api/coffres/distribuer"
|
||||
TIMEOUT_DEFAUT = 10.0
|
||||
|
||||
# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais
|
||||
# la valeur distribuée, ni le jeton consommateur.
|
||||
_MESSAGES_DETAIL = {
|
||||
"consommateur_invalide": (
|
||||
"Consommateur Kanbante invalide dans ObsiGate : "
|
||||
"vérifier le jeton consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"consommateur_revoque": (
|
||||
"Consommateur Kanbante révoqué dans ObsiGate : "
|
||||
"ré-accorder l'accès du consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"octroi_absent": (
|
||||
"Aucun octroi actif pour cette référence dans ObsiGate : "
|
||||
"créer l'octroi pour le consommateur Kanbante dans ObsiGate."
|
||||
),
|
||||
"octroi_revoque": (
|
||||
"Accès révoqué dans ObsiGate pour cette référence : "
|
||||
"ré-accorder l'octroi dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"octroi_expire": (
|
||||
"Octroi expiré dans ObsiGate pour cette référence : "
|
||||
"renouveler l'octroi dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"secret_expire": (
|
||||
"Secret expiré dans ObsiGate : "
|
||||
"renouveler le secret dans ObsiGate, puis réessayer."
|
||||
),
|
||||
"reference_invalide": (
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
),
|
||||
"contexte_manquant": (
|
||||
"Contexte de distribution manquant : "
|
||||
"un contexte (type + identifiant) est obligatoire côté ObsiGate."
|
||||
),
|
||||
"kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.",
|
||||
"crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.",
|
||||
}
|
||||
|
||||
|
||||
class CoffreErreur(Exception):
|
||||
"""Erreur de distribution, sans jamais exposer une valeur secrète."""
|
||||
|
||||
|
||||
class CoffreReferenceInvalide(CoffreErreur):
|
||||
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
|
||||
|
||||
|
||||
class CoffreContexteManquant(CoffreErreur):
|
||||
"""Le contexte de distribution (type + identifiant) est obligatoire."""
|
||||
|
||||
|
||||
class CoffreNonConfigure(CoffreErreur):
|
||||
"""ObsiGate ou le jeton consommateur Kanbante n'est pas configuré."""
|
||||
|
||||
|
||||
class CoffreAccesRefuse(CoffreErreur):
|
||||
"""ObsiGate a refusé la distribution (octroi/consommateur/secret).
|
||||
|
||||
Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit
|
||||
échouer proprement avec ce message.
|
||||
"""
|
||||
|
||||
|
||||
class CoffreIndisponible(CoffreErreur):
|
||||
"""ObsiGate est injoignable ou a répondu sans valeur utilisable."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoffreConfig:
|
||||
"""Configuration du consommateur Kanbante côté ObsiGate."""
|
||||
|
||||
base_url: str
|
||||
jeton_consommateur: str
|
||||
timeout: float = TIMEOUT_DEFAUT
|
||||
|
||||
@property
|
||||
def url_distribuer(self) -> str:
|
||||
return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER
|
||||
|
||||
|
||||
def config_depuis_env() -> CoffreConfig:
|
||||
"""Construit la configuration depuis l'environnement.
|
||||
|
||||
Lève : CoffreNonConfigure si la base ou le jeton est absent.
|
||||
Le jeton n'est jamais journalisé, même en cas d'erreur.
|
||||
"""
|
||||
base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip()
|
||||
jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip()
|
||||
try:
|
||||
timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT)
|
||||
except ValueError:
|
||||
timeout = TIMEOUT_DEFAUT
|
||||
if not base_url or not jeton:
|
||||
raise CoffreNonConfigure(
|
||||
"Consommateur coffre Kanbante non configuré : définir "
|
||||
"KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN."
|
||||
)
|
||||
return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout)
|
||||
|
||||
|
||||
def est_reference(valeur: str | None) -> bool:
|
||||
"""Vrai si ``valeur`` est une référence coffre (et non une valeur brute)."""
|
||||
if not valeur:
|
||||
return False
|
||||
try:
|
||||
parse_reference(valeur)
|
||||
except CoffreReferenceInvalide:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_reference(reference: str) -> tuple[str, str]:
|
||||
"""Valide ``coffre:<slug>/<nom>`` et retourne ``(slug, nom)``.
|
||||
|
||||
Lève : CoffreReferenceInvalide si le format est incorrect.
|
||||
La validation reste volontairement permissive sur les caractères
|
||||
(ObsiGate reste l'autorité de validation) : préfixe, un « / »,
|
||||
slug et nom non vides, aucun espace.
|
||||
"""
|
||||
reference = (reference or "").strip()
|
||||
if not reference.startswith(PREFIXE_REFERENCE):
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
reste = reference[len(PREFIXE_REFERENCE):]
|
||||
if "/" not in reste or any(c.isspace() for c in reference):
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
slug, nom = reste.split("/", 1)
|
||||
if not slug or not nom or "/" in nom:
|
||||
raise CoffreReferenceInvalide(
|
||||
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||
)
|
||||
return slug, nom
|
||||
|
||||
|
||||
def _valider_contexte(contexte_type: str, contexte_id: str) -> None:
|
||||
if not (contexte_type or "").strip() or not (contexte_id or "").strip():
|
||||
raise CoffreContexteManquant(
|
||||
"Contexte de distribution manquant : un contexte "
|
||||
"(type + identifiant) est obligatoire."
|
||||
)
|
||||
|
||||
|
||||
def _message_erreur(statut: int, detail: str) -> CoffreErreur:
|
||||
"""Mappe une réponse d'erreur ObsiGate vers une erreur typée.
|
||||
|
||||
Les statuts 401/403/410 signalent un refus d'accès (octroi,
|
||||
consommateur ou secret) : jamais de repli. Les autres erreurs
|
||||
(400 de format, 503, réponse sans valeur) sont des indisponibilités
|
||||
ou des erreurs de configuration, également sans repli.
|
||||
"""
|
||||
message = _MESSAGES_DETAIL.get(detail)
|
||||
if statut in (401, 403, 410) or detail in (
|
||||
"octroi_absent",
|
||||
"octroi_revoque",
|
||||
"octroi_expire",
|
||||
"consommateur_invalide",
|
||||
"consommateur_revoque",
|
||||
"secret_expire",
|
||||
):
|
||||
return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).")
|
||||
if detail == "reference_invalide":
|
||||
return CoffreReferenceInvalide(message or "Référence invalide.")
|
||||
if detail == "contexte_manquant":
|
||||
return CoffreContexteManquant(message or "Contexte manquant.")
|
||||
return CoffreIndisponible(
|
||||
message or f"ObsiGate indisponible (statut {statut})."
|
||||
)
|
||||
|
||||
|
||||
def _traiter_reponse(reponse: httpx.Response, reference: str) -> str:
|
||||
"""Extrait la valeur d'une réponse de distribution, ou lève.
|
||||
|
||||
La valeur extraite n'est jamais journalisée ; seuls la référence,
|
||||
le contexte et le statut le sont.
|
||||
"""
|
||||
if reponse.status_code == 200:
|
||||
try:
|
||||
valeur = reponse.json().get("valeur")
|
||||
except ValueError:
|
||||
valeur = None
|
||||
if isinstance(valeur, str) and valeur:
|
||||
logger.info("distribution coffre ok (reference=%s)", reference)
|
||||
return valeur
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate a répondu sans valeur distribuable."
|
||||
)
|
||||
try:
|
||||
detail = str(reponse.json().get("detail") or "")
|
||||
except ValueError:
|
||||
detail = ""
|
||||
logger.warning(
|
||||
"distribution coffre refusee (reference=%s, statut=%d, detail=%s)",
|
||||
reference,
|
||||
reponse.status_code,
|
||||
detail,
|
||||
)
|
||||
raise _message_erreur(reponse.status_code, detail)
|
||||
|
||||
|
||||
def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict:
|
||||
return {
|
||||
"reference": reference,
|
||||
"contexte_type": contexte_type,
|
||||
"contexte_id": contexte_id,
|
||||
}
|
||||
|
||||
|
||||
def distribuer(
|
||||
reference: str,
|
||||
contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Distribue une valeur ObsiGate, en mémoire seulement.
|
||||
|
||||
L'appelant doit utiliser la valeur immédiatement (appel sortant,
|
||||
vérification HMAC) puis la laisser sortir de portée : ce module ne
|
||||
la persiste ni ne la met en cache, et ne la journalise jamais.
|
||||
"""
|
||||
parse_reference(reference)
|
||||
_valider_contexte(contexte_type, contexte_id)
|
||||
cfg = config or config_depuis_env()
|
||||
try:
|
||||
with httpx.Client(timeout=cfg.timeout, transport=transport) as client:
|
||||
reponse = client.post(
|
||||
cfg.url_distribuer,
|
||||
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate injoignable pour la distribution : "
|
||||
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||
) from exc
|
||||
return _traiter_reponse(reponse, reference)
|
||||
|
||||
|
||||
async def distribuer_async(
|
||||
reference: str,
|
||||
contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
) -> str:
|
||||
"""Variante asynchrone de :func:`distribuer` (mêmes garanties)."""
|
||||
parse_reference(reference)
|
||||
_valider_contexte(contexte_type, contexte_id)
|
||||
cfg = config or config_depuis_env()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client:
|
||||
reponse = await client.post(
|
||||
cfg.url_distribuer,
|
||||
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CoffreIndisponible(
|
||||
"ObsiGate injoignable pour la distribution : "
|
||||
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||
) from exc
|
||||
return _traiter_reponse(reponse, reference)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def usage_distribue(reference: str, contexte_type: str, contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.BaseTransport | None = None):
|
||||
"""Distribue une valeur pour un seul usage, dans le bloc ``with``.
|
||||
|
||||
La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée,
|
||||
ni retournée) : à la sortie, l'appelant ne conserve que le résultat
|
||||
de l'usage, jamais la valeur elle-même.
|
||||
"""
|
||||
valeur = distribuer(reference, contexte_type, contexte_id,
|
||||
config=config, transport=transport)
|
||||
try:
|
||||
yield valeur
|
||||
finally:
|
||||
valeur = ""
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def usage_distribue_async(reference: str, contexte_type: str,
|
||||
contexte_id: str,
|
||||
config: CoffreConfig | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None):
|
||||
"""Variante asynchrone de :func:`usage_distribue`."""
|
||||
valeur = await distribuer_async(reference, contexte_type, contexte_id,
|
||||
config=config, transport=transport)
|
||||
try:
|
||||
yield valeur
|
||||
finally:
|
||||
valeur = ""
|
||||
+1
-1
@@ -177,7 +177,7 @@ from urllib.parse import urlparse
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.35.0"
|
||||
APP_VERSION = "0.37.1"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+260
-44
@@ -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
|
||||
@@ -51,6 +52,7 @@ from app import forecast as forecast_service
|
||||
from app import rules as rules_service
|
||||
from app import sprint as sprint_service
|
||||
from app import connections as connections_service
|
||||
from app import coffre_client
|
||||
from app import board_columns as bcols
|
||||
from app.briefing import (
|
||||
format_briefing_text,
|
||||
@@ -1148,6 +1150,12 @@ async def admin_create_connection(
|
||||
return RedirectResponse(
|
||||
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
||||
)
|
||||
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||
return RedirectResponse(
|
||||
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||
status_code=303,
|
||||
)
|
||||
slug = _unique_slug(_slugify(name), "connections")
|
||||
conn_id = models.create_connection(
|
||||
slug=slug,
|
||||
@@ -1192,6 +1200,12 @@ async def admin_update_connection(
|
||||
if token:
|
||||
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
||||
if hook_secret:
|
||||
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||
return RedirectResponse(
|
||||
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||
status_code=303,
|
||||
)
|
||||
fields["webhook_secret"] = hook_secret
|
||||
if fields:
|
||||
models.update_connection(conn_id, **fields)
|
||||
@@ -4878,7 +4892,50 @@ async def _handle_forge_webhook(request: Request, conn_row):
|
||||
settings = get_settings()
|
||||
raw = await request.body()
|
||||
kind = conn_row["kind"] if conn_row else "gitea"
|
||||
secret = (conn_row or {}).get("webhook_secret") or settings.webhook_secret
|
||||
# Secret webhook : une référence coffre:<slug>/<nom> stockée dans
|
||||
# forge_connections.webhook_secret est distribuée à chaque webhook
|
||||
# (Phase 4, issue ObsiGate #22), utilisée en mémoire pour ce seul
|
||||
# HMAC, puis oubliée. Aucun repli : si la distribution échoue
|
||||
# (octroi révoqué, ObsiGate indisponible ou consommateur non
|
||||
# configuré), le webhook échoue proprement avec un message explicite
|
||||
# orientant vers ObsiGate, sans retomber sur le secret d'environnement
|
||||
# ni sur une ancienne valeur (il n'y en a pas de stockée).
|
||||
# Les valeurs historiques en clair et le repli environnement restent
|
||||
# inchangés (compatibilité) tant qu'aucune référence n'est saisie.
|
||||
stocke = (conn_row or {}).get("webhook_secret") or ""
|
||||
if coffre_client.est_reference(stocke):
|
||||
try:
|
||||
secret = await coffre_client.distribuer_async(
|
||||
stocke,
|
||||
contexte_type="webhook",
|
||||
contexte_id=(conn_row or {}).get("slug") or "gitea",
|
||||
)
|
||||
except coffre_client.CoffreAccesRefuse as exc:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "Accès révoqué ou refusé dans ObsiGate : "
|
||||
"ré-accorder l'octroi du secret webhook pour le "
|
||||
"consommateur Kanbante dans ObsiGate, puis renvoyer "
|
||||
"le webhook.",
|
||||
"detail": str(exc),
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
except coffre_client.CoffreErreur as exc:
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "ObsiGate indisponible ou consommateur Kanbante "
|
||||
"non configuré : impossible de vérifier ce webhook "
|
||||
"sans distribuer son secret. Vérifier ObsiGate, puis "
|
||||
"renvoyer le webhook.",
|
||||
"detail": str(exc),
|
||||
},
|
||||
status_code=503,
|
||||
)
|
||||
else:
|
||||
secret = stocke or settings.webhook_secret
|
||||
if secret:
|
||||
if kind == "github":
|
||||
signature = request.headers.get("x-hub-signature-256", "")
|
||||
@@ -5402,38 +5459,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 +5512,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 +5526,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 +5538,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 +5591,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 +6329,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 +6431,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 +6622,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 +6692,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 +6701,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).
|
||||
"""
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||
<label>Secret webhook (vide = inchangé, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
@@ -68,10 +68,10 @@
|
||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
||||
<label>Secret webhook (optionnel, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||
</form>
|
||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||
<p class="notes-hint">Secret webhook : une référence <code>coffre:<slug>/<nom></code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -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())">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
|
||||
|
||||
Patron « même patron, sans variante » de la spec « Coffre de secrets
|
||||
ObsiGate » §12, appliqué à Kanbante sur la branche
|
||||
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
|
||||
|
||||
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
|
||||
|
||||
| Secret | Stockage actuel | Statut dans ce lot |
|
||||
|---|---|---|
|
||||
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
|
||||
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
|
||||
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
|
||||
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
|
||||
|
||||
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
|
||||
persisté par l'app — le secret webhook — et c'est celui qui est migré.
|
||||
Le client générique ci-dessous reste disponible pour le premier usage
|
||||
réel suivant (jetons forge, voir §5).
|
||||
|
||||
## 2. Ce qui est implémenté
|
||||
|
||||
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
|
||||
|
||||
- `est_reference()` / `parse_reference()` : validation du format
|
||||
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
|
||||
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
|
||||
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
|
||||
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
|
||||
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
|
||||
premiers → `CoffreNonConfigure`, échec propre.
|
||||
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
|
||||
avec JSON `{reference, contexte_type, contexte_id}` et
|
||||
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
|
||||
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
|
||||
retournée n'est **jamais journalisée, jamais persistée, jamais mise
|
||||
en cache**.
|
||||
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
|
||||
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
|
||||
- Erreurs typées, sans repli, avec messages explicites orientant vers
|
||||
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
|
||||
message, un log ou une réponse HTTP.
|
||||
|
||||
### Webhook (`app/main.py`, `_handle_forge_webhook`)
|
||||
|
||||
- Si `webhook_secret` stocké est une référence : distribution
|
||||
asynchrone à chaque webhook (`contexte_type="webhook"`,
|
||||
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
|
||||
mémoire, puis oubli (fin de portée de la fonction).
|
||||
- Échec de distribution → **aucun repli** sur le secret
|
||||
d'environnement ni sur une ancienne valeur (il n'y en a pas de
|
||||
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
|
||||
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
|
||||
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
|
||||
explicite. Le webhook doit être renvoyé après correction côté
|
||||
ObsiGate.
|
||||
- Si la valeur stockée n'est pas une référence : comportement historique
|
||||
inchangé (secret en clair, puis repli environnement).
|
||||
|
||||
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
|
||||
|
||||
- Le champ « Secret webhook » accepte désormais une référence
|
||||
`coffre:<slug>/<nom>` (création et modification). Une saisie
|
||||
commençant par `coffre:` mais mal formée est **refusée** (redirection
|
||||
d'erreur), pour ne jamais la stocker comme secret en clair historique
|
||||
par accident.
|
||||
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
|
||||
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
|
||||
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
|
||||
Le bandeau du formulaire le signale.
|
||||
|
||||
Aucun changement de schéma n'a été nécessaire : la référence est
|
||||
stockée dans la colonne `webhook_secret` existante, détectée par son
|
||||
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
|
||||
sans avoir été saisi comme tel, et ce cas est désormais validé).
|
||||
|
||||
## 3. Correspondance des erreurs ObsiGate
|
||||
|
||||
| Réponse ObsiGate | Erreur Kanbante | Effet |
|
||||
|---|---|---|
|
||||
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
|
||||
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
|
||||
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
|
||||
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
|
||||
|
||||
Dans tous les cas : pas de valeur en cache, pas de continuation.
|
||||
|
||||
## 4. Tests
|
||||
|
||||
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
|
||||
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
|
||||
|
||||
- validation de références (valides / invalides) ;
|
||||
- succès sync + async, avec vérification de la requête (chemin, Bearer,
|
||||
corps `reference` + contexte) ;
|
||||
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
|
||||
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
|
||||
- indisponibilité (503, erreur réseau, 200 sans valeur) →
|
||||
`CoffreIndisponible` ;
|
||||
- référence invalide / contexte manquant / configuration absente :
|
||||
aucun appel HTTP ;
|
||||
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
|
||||
sans repli ; distribution réussie → HMAC accepté et ligne en base
|
||||
toujours porteuse de la référence, jamais de la valeur.
|
||||
|
||||
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
|
||||
|
||||
1. **Validation réelle** : enregistrer le consommateur « kanbante »
|
||||
dans ObsiGate, créer un octroi vers un secret webhook de test,
|
||||
configurer `KANBANTE_OBSIGATE_URL` +
|
||||
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
|
||||
la référence dans `/admin/connections`, envoyer un vrai webhook
|
||||
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
|
||||
le 401 explicite, puis couper ObsiGate et vérifier le 503.
|
||||
2. **Jetons forge par référence** : distribution à chaque appel dans
|
||||
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
|
||||
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
|
||||
référence dans le champ « Jeton d'accès » et documenter la migration
|
||||
des connexions existantes (chiffrées Fernet → références).
|
||||
3. **Migration opportuniste des secrets webhook historiques** : proposer
|
||||
en admin la conversion d'un secret en clair existant vers une
|
||||
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
|
||||
remplace la valeur par la référence) — aucun outil automatique ne
|
||||
copie une valeur réelle vers ObsiGate dans ce lot.
|
||||
4. **CI complète** : la suite n'a été exécutée que localement dans le
|
||||
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
|
||||
observer avant toute PR.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.35.0"
|
||||
version = "0.37.1"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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,227 @@
|
||||
"""Tests du client de distribution coffre ObsiGate (Phase 4, issue ObsiGate #22).
|
||||
|
||||
Aucune instance ObsiGate réelle n'est contactée : le « faux endpoint » est
|
||||
un ``httpx.MockTransport`` local, et toutes les valeurs distribuées dans
|
||||
ces tests sont fictives (``TEST-VALUE-XXXX``), jamais des secrets réels.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import app.main as main
|
||||
from app import models
|
||||
from app.coffre_client import (
|
||||
CoffreAccesRefuse,
|
||||
CoffreConfig,
|
||||
CoffreContexteManquant,
|
||||
CoffreIndisponible,
|
||||
CoffreNonConfigure,
|
||||
CoffreReferenceInvalide,
|
||||
distribuer,
|
||||
distribuer_async,
|
||||
est_reference,
|
||||
parse_reference,
|
||||
usage_distribue,
|
||||
)
|
||||
|
||||
# Configuration fictive, réservée aux tests.
|
||||
CONFIG_TEST = CoffreConfig(
|
||||
base_url="https://obsigate.test",
|
||||
jeton_consommateur="TEST-CONSUMER-TOKEN",
|
||||
)
|
||||
REFERENCE_TEST = "coffre:TEST-SLUG/TEST-NOM"
|
||||
VALEUR_TEST = "TEST-VALUE-XXXX"
|
||||
|
||||
|
||||
def _transport_faux(statut=200, corps=None, verifier=None):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if verifier is not None:
|
||||
verifier(request)
|
||||
return httpx.Response(statut, json=corps if corps is not None else {})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _verifier_requete(request: httpx.Request):
|
||||
assert request.url.path == "/api/coffres/distribuer"
|
||||
assert request.headers["Authorization"] == "Bearer TEST-CONSUMER-TOKEN"
|
||||
corps = json.loads(request.content.decode())
|
||||
assert corps["reference"] == REFERENCE_TEST
|
||||
assert corps["contexte_type"] == "webhook"
|
||||
assert corps["contexte_id"] == "gitea"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reference, attendu",
|
||||
[
|
||||
("coffre:personnel/gitea", True),
|
||||
("coffre:TEST-SLUG/TEST-NOM", True),
|
||||
("coffre:a/b/c", False), # un seul « / » après le préfixe
|
||||
("coffre:/nom", False),
|
||||
("coffre:slug/", False),
|
||||
("coffre:slug", False),
|
||||
("gitea", False),
|
||||
("", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_reference_validation(reference, attendu):
|
||||
assert est_reference(reference) is attendu
|
||||
if attendu:
|
||||
assert parse_reference(reference)[0]
|
||||
else:
|
||||
with pytest.raises(CoffreReferenceInvalide):
|
||||
parse_reference(reference or "")
|
||||
|
||||
|
||||
def test_distribuer_succes_sync():
|
||||
transport = _transport_faux(
|
||||
200,
|
||||
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||
verifier=_verifier_requete,
|
||||
)
|
||||
assert (
|
||||
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||
== VALEUR_TEST
|
||||
)
|
||||
|
||||
|
||||
async def test_distribuer_succes_async():
|
||||
transport = _transport_faux(
|
||||
200,
|
||||
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||
verifier=_verifier_requete,
|
||||
)
|
||||
assert (
|
||||
await distribuer_async(
|
||||
REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport
|
||||
)
|
||||
== VALEUR_TEST
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"statut, detail, message_attendu",
|
||||
[
|
||||
(403, "octroi_revoque", "ré-accorder"),
|
||||
(403, "octroi_expire", "renouveler"),
|
||||
(403, "octroi_absent", "octroi"),
|
||||
(401, "consommateur_revoque", "ré-accorder"),
|
||||
(401, "consommateur_invalide", "invalide"),
|
||||
(410, "secret_expire", "expiré"),
|
||||
],
|
||||
)
|
||||
def test_distribuer_refus_sans_repli(statut, detail, message_attendu):
|
||||
transport = _transport_faux(statut, {"detail": detail})
|
||||
with pytest.raises(CoffreAccesRefuse) as excinfo:
|
||||
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||
assert message_attendu in str(excinfo.value)
|
||||
assert VALEUR_TEST not in str(excinfo.value)
|
||||
|
||||
|
||||
def test_distribuer_indisponible_503_et_reseau():
|
||||
transport = _transport_faux(503, {"detail": "kek_absente"})
|
||||
with pytest.raises(CoffreIndisponible):
|
||||
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||
|
||||
def _panne(request: httpx.Request):
|
||||
raise httpx.ConnectError("faux ObsiGate injoignable", request=request)
|
||||
|
||||
with pytest.raises(CoffreIndisponible):
|
||||
distribuer(
|
||||
REFERENCE_TEST, "webhook", "gitea",
|
||||
config=CONFIG_TEST, transport=httpx.MockTransport(_panne),
|
||||
)
|
||||
|
||||
|
||||
def test_distribuer_sans_valeur_malgre_200():
|
||||
transport = _transport_faux(200, {"reference": REFERENCE_TEST})
|
||||
with pytest.raises(CoffreIndisponible):
|
||||
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||
|
||||
|
||||
def test_distribuer_reference_invalide_sans_appel():
|
||||
def _interdit(request: httpx.Request):
|
||||
raise AssertionError("aucun appel HTTP ne doit partir")
|
||||
|
||||
with pytest.raises(CoffreReferenceInvalide):
|
||||
distribuer("valeur-brute", "webhook", "gitea",
|
||||
config=CONFIG_TEST, transport=httpx.MockTransport(_interdit))
|
||||
|
||||
|
||||
def test_distribuer_contexte_manquant_sans_appel():
|
||||
with pytest.raises(CoffreContexteManquant):
|
||||
distribuer(REFERENCE_TEST, "", "gitea", config=CONFIG_TEST)
|
||||
|
||||
|
||||
def test_distribuer_non_configure(monkeypatch):
|
||||
monkeypatch.delenv("KANBANTE_OBSIGATE_URL", raising=False)
|
||||
monkeypatch.delenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", raising=False)
|
||||
with pytest.raises(CoffreNonConfigure):
|
||||
distribuer(REFERENCE_TEST, "webhook", "gitea")
|
||||
|
||||
|
||||
def test_usage_distribue_ne_conserve_que_le_resultat():
|
||||
transport = _transport_faux(200, {"valeur": VALEUR_TEST})
|
||||
# Le bloc ne sert qu'à l'usage : ici, dériver un HMAC, comme le ferait
|
||||
# la vérification d'un webhook. Seul le résultat de l'usage est
|
||||
# conservé après le bloc, jamais la valeur distribuée.
|
||||
with usage_distribue(
|
||||
REFERENCE_TEST, "webhook", "gitea",
|
||||
config=CONFIG_TEST, transport=transport,
|
||||
) as valeur:
|
||||
signature = hmac.new(valeur.encode(), b"corps-test", hashlib.sha256).hexdigest()
|
||||
assert len(signature) == 64
|
||||
|
||||
|
||||
def test_webhook_reference_octroi_revoque_sans_repli(client, monkeypatch):
|
||||
"""Référence webhook + octroi révoqué -> 401 explicite, sans repli."""
|
||||
models.create_connection(
|
||||
"coffre-webhook", "gitea", "Coffre webhook", "https://gitea.test",
|
||||
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||
)
|
||||
|
||||
async def _refuse(*args, **kwargs):
|
||||
raise CoffreAccesRefuse("Accès révoqué dans ObsiGate pour cette référence.")
|
||||
|
||||
monkeypatch.setattr(main.coffre_client, "distribuer_async", _refuse)
|
||||
reponse = client.post(
|
||||
"/webhooks/coffre-webhook",
|
||||
content=b"{}",
|
||||
headers={"x-gitea-signature": "0" * 64},
|
||||
)
|
||||
assert reponse.status_code == 401
|
||||
assert "ObsiGate" in reponse.json()["error"]
|
||||
|
||||
|
||||
def test_webhook_reference_distribuee_puis_oubliee(client, monkeypatch):
|
||||
"""Référence webhook distribuée -> HMAC vérifié, valeur non persistée."""
|
||||
models.create_connection(
|
||||
"coffre-webhook-ok", "gitea", "Coffre webhook OK", "https://gitea.test",
|
||||
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||
)
|
||||
|
||||
async def _distribue(reference, contexte_type, contexte_id):
|
||||
assert reference == REFERENCE_TEST
|
||||
assert contexte_type == "webhook"
|
||||
assert contexte_id == "coffre-webhook-ok"
|
||||
return VALEUR_TEST
|
||||
|
||||
monkeypatch.setattr(main.coffre_client, "distribuer_async", _distribue)
|
||||
corps = b"{}"
|
||||
signature = hmac.new(VALEUR_TEST.encode(), corps, hashlib.sha256).hexdigest()
|
||||
reponse = client.post(
|
||||
"/webhooks/coffre-webhook-ok",
|
||||
content=corps,
|
||||
headers={"x-gitea-signature": signature},
|
||||
)
|
||||
assert reponse.status_code == 200
|
||||
# La valeur distribuée n'a jamais été persistée : la ligne stocke
|
||||
# toujours la référence, jamais la valeur fictive.
|
||||
ligne = models.get_connection_by_slug("coffre-webhook-ok")
|
||||
assert ligne["webhook_secret"] == REFERENCE_TEST
|
||||
assert VALEUR_TEST not in (ligne["webhook_secret"] or "")
|
||||
+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,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