Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7852d860fe | ||
|
|
d4cf8d1275 | ||
|
|
28322c12cf | ||
|
|
3964323abd | ||
|
|
4230ac1052 | ||
|
|
3809462cc7 | ||
|
|
868496891d | ||
|
|
4a947747f2 | ||
|
|
5ef6c21756 | ||
|
|
13182f5d53 | ||
|
|
2381ae0b35 |
+252
@@ -1,5 +1,257 @@
|
||||
# Changelog
|
||||
|
||||
## 0.31.1 — 2026-10-23
|
||||
|
||||
### Corrigé — compositeur de création refait
|
||||
|
||||
Retour de Bruno sur la 0.31.0 : la barre de création
|
||||
n'était ni élégante ni professionnelle, et le bouton
|
||||
« + Description » pouvait rester sans effet (écouteur
|
||||
JavaScript non enregistré selon l'état du cache ou des
|
||||
scripts de la page).
|
||||
|
||||
- **Compositeur en carte** : en-tête « Nouvelle issue »
|
||||
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
|
||||
nom personnalisé de l'espace respecté), champ titre
|
||||
pleine largeur avec anneau de focus, bouton primaire
|
||||
« Créer l'issue ».
|
||||
- **Description en `<details>` natif** : « Ajouter une
|
||||
description ▾ » déplie la zone texte sans aucun
|
||||
JavaScript — impossible à casser par un script en
|
||||
erreur ou un fichier en cache. Aide intégrée (Markdown,
|
||||
Ctrl+Entrée pour créer, actif aussi depuis le titre).
|
||||
- **Assets du board versionnés** (`?v=<version>` sur le
|
||||
CSS et board.js du board) : un redéploiement invalide
|
||||
le cache navigateur au lieu de servir un mélange
|
||||
d'anciennes et nouvelles pièces.
|
||||
- Vérifié en navigateur réel (Chromium) sur le vrai
|
||||
board : captures fermé/ouvert contrôlées visuellement,
|
||||
clic sur le libellé, zone visible et éditable.
|
||||
|
||||
|
||||
## 0.31.0 — 2026-10-23
|
||||
|
||||
### Ajouté — description éditable, pièces jointes, clic fiable
|
||||
|
||||
Retours de Bruno : impossible d'ajouter une description
|
||||
après création, aucun moyen de joindre des fichiers comme
|
||||
dans Gitea, et clic sur les cartes capricieux.
|
||||
|
||||
- **Titre et description éditables** dans le tiroir de
|
||||
détail (bouton « ✏️ Modifier le titre et la description ») :
|
||||
l'enregistrement écrit directement dans l'issue de la
|
||||
forge (PATCH), le journal consigne « edited ✏️ », et la
|
||||
carte du board reflète le nouveau titre sans recharger.
|
||||
- **Description dès la création** : le formulaire « Nouvelle
|
||||
issue » gagne un bouton « + Description » qui déplie un
|
||||
champ texte transmis à la création.
|
||||
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
|
||||
liste des fichiers (nom, taille, date), **ajout** par le
|
||||
sélecteur de fichier (25 Mio max), **téléchargement**
|
||||
proxifié par Kanbante (le jeton ne quitte jamais le
|
||||
serveur ; suit le `browser_download_url` de Gitea, dont
|
||||
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
|
||||
au smoke réel), **suppression** avec confirmation. Sur
|
||||
GitHub, l'API ne gère pas les pièces jointes d'issues :
|
||||
l'écran l'explique et renvoie à l'interface web.
|
||||
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
|
||||
passe en **délégation d'événement** — avant, l'écouteur
|
||||
était posé carte par carte au chargement ; le premier
|
||||
rafraîchissement temps réel remplaçait les fragments et
|
||||
les cartes devenaient muettes. Le seuil anti-glisser
|
||||
(6 px) et l'exclusion liens/boutons sont conservés, et
|
||||
les cartes affichent un curseur pointeur.
|
||||
|
||||
|
||||
## 0.30.0 — 2026-10-23
|
||||
|
||||
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
|
||||
|
||||
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
|
||||
des colonnes (Backlog, À faire, En cours, …) et configurer le
|
||||
mapping label/colonne depuis l'interface web.
|
||||
|
||||
- **Colonnes par espace** (Administration → Colonnes, ou le
|
||||
bouton 🧱 Colonnes sur un board) : ajouter une colonne
|
||||
(insérée avant Terminé), la renommer (le nom affiché est
|
||||
libre, émojis compris), changer sa couleur, la monter ou la
|
||||
descendre, la supprimer — ses cartes sont alors déplacées
|
||||
vers une colonne de destination choisie. Réinitialisation
|
||||
aux six colonnes historiques en un clic. Chaque espace a SES
|
||||
colonnes.
|
||||
- **Clés immuables** : une colonne garde sa clé interne
|
||||
(`en_developpement`) quoi qu'on renomme — les cartes, les
|
||||
limites WIP et les règles y restent rattachés. Backlog et
|
||||
Terminé sont des colonnes système (renommables, non
|
||||
supprimables) : défaut des cartes neuves, et issues fermées.
|
||||
- **Mapping étiquette ↔ colonne configurable** : chaque
|
||||
colonne de flux a son **étiquette mappée** (défaut
|
||||
`kanban:<clé>`) posée sur l'issue à chaque déplacement
|
||||
(miroir), plus des **alias** reconnus à l'adoption — par
|
||||
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
|
||||
alias de la colonne À faire fait adopter cette colonne par
|
||||
le board. Adoption : exactement une étiquette reconnue,
|
||||
comme avant.
|
||||
- **Focus / Wallboard** : chaque colonne déclare si elle y
|
||||
figure (case « Dans Focus / Wallboard ») ; les nouvelles
|
||||
colonnes y sont par défaut.
|
||||
- Sans personnalisation, tout est strictement identique à
|
||||
avant (définitions semées paresseusement depuis les valeurs
|
||||
historiques). Le triage au clavier garde ses touches
|
||||
historiques (1 = À faire, 2 = En cours, …).
|
||||
|
||||
|
||||
## 0.29.0 — 2026-10-23
|
||||
|
||||
### Modifié — panneau Spark V3 : la console en surimpression pleine page
|
||||
|
||||
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
|
||||
plus en nouvel onglet : la console web du service d'agents
|
||||
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
|
||||
page**, en surimpression qui recouvre tout l'écran avec la même
|
||||
animation d'ouverture que la barre latérale (panneau vendangé
|
||||
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
|
||||
styles de la surimpression sont injectés par le panneau lui-même).
|
||||
La barre de la surimpression offre les deux sorties : « Mode
|
||||
sidebar » referme la console et rouvre l'agent en barre latérale,
|
||||
« Fermer l'agent » ferme la surimpression et le panneau. La
|
||||
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
|
||||
passent comme avant à la console. Côté service d'agents : la
|
||||
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
|
||||
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
|
||||
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.1 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark : bouton « Console ↗ »
|
||||
|
||||
Bouton « Console ↗ » dans la barre des discussions du panneau
|
||||
Spark (demande de Bruno) : ouvre la console web du service
|
||||
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
|
||||
sur la discussion actuellement affichée (`?disc=<id>`, URL
|
||||
construite au clic) et avec l'app d'origine (`?app=kanbante`,
|
||||
déduite du sous-domaine). Panneau recopié octet-identique dans
|
||||
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
|
||||
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
|
||||
|
||||
## 0.28.0 — 2026-10-22
|
||||
|
||||
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
|
||||
|
||||
Kanbante émet désormais ses changements de colonne au journal
|
||||
d'activité cross-app du service d'agents (contrat ACC-0.2) :
|
||||
`kanbante.card.moved` pour tout déplacement réel d'une carte —
|
||||
glisser-déposer de l'interface, adoption d'une étiquette
|
||||
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
|
||||
forge (webhook ou réconciliation à l'affichage), fusion de PR,
|
||||
création de branche — et `kanbante.card.review_requested` quand
|
||||
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
|
||||
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
|
||||
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
|
||||
arrière-plan : inactive sans clé, et une panne du service
|
||||
d'agents n'affecte jamais un déplacement. Aucun changement de
|
||||
comportement visible. Redéploiement Docker requis.
|
||||
|
||||
## 0.27.0 — 2026-10-22
|
||||
|
||||
### Ajouté — panneau Spark v2 (Lot 1)
|
||||
|
||||
Le panneau vendangé passe en v2, à l'identique dans les 4 apps :
|
||||
réponses de l'agent en **Markdown rendu** (HTML du service
|
||||
d'agents 0.10.0, sanitizé côté serveur, rapports de missions
|
||||
compris), **largeur réglable** persistée, **thème clair/sombre**
|
||||
aligné sur l'app hôte ou le système, et **discussions façon
|
||||
Muse** : discussion principale perpétuelle + parallèles
|
||||
créées/archivées/supprimées depuis le tiroir du panneau.
|
||||
Redéploiement Docker requis.
|
||||
|
||||
## 0.26.0 — 2026-10-21
|
||||
|
||||
### Ajouté — panneau Spark intégré
|
||||
|
||||
Le bouton Spark de la barre commune ouvre désormais le panneau de
|
||||
l'agent commun (conversation, Aujourd'hui, approbations, missions),
|
||||
comme dans TikTak et Flowdeck : assets `draco-panneau.js/css`
|
||||
vendangés à l'identique, action `spark` branchée sur
|
||||
`DRACO_PANNEAU.toggle()`. Redéploiement Docker requis (avec le reste
|
||||
de la 0.26.0).
|
||||
|
||||
### Ajouté — écritures MCP minimales et sûres (service d'agents)
|
||||
|
||||
Le serveur MCP n'était qu'en lecture depuis la 0.5.0. Le service
|
||||
d'agents (jeton `kbt_` posé en production) peut désormais créer
|
||||
des cartes et commenter, et rien d'autre : aucune suppression,
|
||||
aucun changement d'état.
|
||||
|
||||
- **`create_issue`** — arguments : `owner` (chaîne, requis),
|
||||
`repo` (chaîne, requis), `title` (chaîne, requis), `body`
|
||||
(chaîne, optionnel), `column` (optionnel, une des colonnes du
|
||||
board, `backlog` par défaut). Réutilise le service de création
|
||||
existant (`_do_capture` → `create_issue` du client de forge,
|
||||
Gitea ou GitHub selon la connexion qui détient le dépôt) : la
|
||||
carte est placée sur le board et journalisée (source `mcp`).
|
||||
Réponse JSON : `number`, `title`, `html_url`, `repo_full_name`,
|
||||
`column`.
|
||||
- **`add_comment`** — arguments : `owner`, `repo`, `number`
|
||||
(entier), `body` — tous requis. Réutilise `create_comment` du
|
||||
client de forge (mêmes validations que l'API web : corps non
|
||||
vide, 10 000 caractères max) et journalise l'événement
|
||||
`commented` (source `mcp`). Réponse JSON : `id`, `body`,
|
||||
`html_url`, `repo_full_name`, `number`.
|
||||
- **Garde inchangée** : les deux outils passent par le même
|
||||
`/mcp` et le même middleware que les lectures — session,
|
||||
Basic ou `Authorization: Bearer kbt_…` ; un jeton ne
|
||||
déverrouille toujours pas l'interface web, et sa révocation
|
||||
bloque aussitôt les écritures. Erreurs métier au format
|
||||
existant du serveur (résultat `isError` avec le message).
|
||||
- `GET /mcp` déclare désormais `read_only: false` et la liste
|
||||
`write_tools` (honnêteté de la découverte).
|
||||
- **Redéploiement Docker de Kanbante à la charge de Bruno**
|
||||
pour activer cette version en production.
|
||||
|
||||
## 0.25.0 — 2026-10-21
|
||||
|
||||
### Ajouté — jetons d'API personnels pour les surfaces machine (MCP)
|
||||
|
||||
Depuis la 0.24.0, tout Kanbante exige une authentification (session
|
||||
ou Basic) — y compris `/mcp`. Le service d'agents s'y faisait refuser
|
||||
en 401 (vu en production le 2026-10-21, panneau Spark : « Source
|
||||
Kanbante injoignable »). Il manquait une identité machine propre.
|
||||
|
||||
- Table `api_tokens` : jetons personnels préfixés `kbt_`, seule
|
||||
l'empreinte SHA-256 est conservée ; création, liste et révocation
|
||||
dans **Mon profil → Jetons d'API (MCP)**, texte clair affiché une
|
||||
seule fois à la création.
|
||||
- Le middleware honore `Authorization: Bearer kbt_…` **uniquement**
|
||||
sur `/mcp` et `/api/` : un jeton ne déverrouille jamais l'interface
|
||||
web. Révocation et désactivation du compte ont effet immédiat ;
|
||||
`last_used_at` journalise l'usage.
|
||||
- Usage visé : créer un jeton « service-agents » et le poser en
|
||||
`AGENT_MCP_KANBANTE_KEY` dans le `.env` du conteneur agent.
|
||||
|
||||
## 0.24.2 — 2026-10-21
|
||||
|
||||
### Corrigé — boîtes d'édition blanches dans l'administration (#29)
|
||||
|
||||
Signalé par Bruno (captures) : le champ « Nouveau mot de passe »
|
||||
(Administration), la zone « Sélection de projets » (Espaces) et
|
||||
les champs « Valeur du panneau » (Paramètres) s'affichaient en
|
||||
**blanc**. Il n'existait aucune règle de base pour les champs de
|
||||
saisie : seuls les champs couverts par un contexte précis
|
||||
(`.login-form`, `.capture-form`, `.admin-form` pour text/password/
|
||||
select seulement) étaient sombres — les textarea et tout champ
|
||||
hors de ces contextes gardaient le fond du navigateur.
|
||||
|
||||
Correctif dans `style.css` : une **règle de base unique en
|
||||
spécificité zéro** (`:where(...)`) — fond `--surface-2`, texte
|
||||
`--text`, bordure `--border` — pour `input`, `textarea` et
|
||||
`select`, qui comble les vides sans jamais écraser les règles de
|
||||
contexte existantes ; plus `color-scheme: dark` pour les
|
||||
contrôles natifs. Styles calculés vérifiés sur les trois pages
|
||||
(fond identique aux champs de référence, palette de commandes
|
||||
inchangée).
|
||||
|
||||
|
||||
## 0.24.0 — 2026-10-21
|
||||
|
||||
### Ajouté — multi-utilisateurs, multi-forges, espaces, archives
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
|
||||
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
|
||||
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
|
||||
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🤖 **Serveur MCP** — 7 outils de lecture (boards, focus, recherche, métriques) + 2 écritures minimales (`create_issue`, `add_comment`) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
|
||||
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
|
||||
- 📝 **Notes** — bloc-notes par projet
|
||||
@@ -212,15 +212,17 @@ kanbante/
|
||||
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
|
||||
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
|
||||
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
|
||||
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
|
||||
| GET/POST | `/mcp` | Serveur MCP (JSON-RPC 2.0) : lectures + `create_issue` / `add_comment` |
|
||||
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
|
||||
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
|
||||
|
||||
## Brancher un client MCP
|
||||
|
||||
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
|
||||
les lectures d'abord). Configuration type (transport HTTP) :
|
||||
Kanbante expose un serveur MCP : lectures d'abord, plus deux
|
||||
écritures minimales et sûres pour les agents — créer une carte et
|
||||
commenter (aucune suppression, aucun changement d'état).
|
||||
Configuration type (transport HTTP) :
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -233,9 +235,13 @@ les lectures d'abord). Configuration type (transport HTTP) :
|
||||
```
|
||||
|
||||
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
|
||||
Kanbante est active, le client doit envoyer l'en-tête
|
||||
`Authorization` correspondant.
|
||||
`search_issues`, `get_activity`, `get_metrics`, `create_issue`
|
||||
(`owner`, `repo`, `title`, `body` et `column` optionnels),
|
||||
`add_comment` (`owner`, `repo`, `number`, `body`). Si
|
||||
l'authentification Kanbante est active, le client doit envoyer
|
||||
l'en-tête `Authorization` correspondant : Basic, ou un jeton
|
||||
d'API personnel `Bearer kbt_…` (Mon profil → Jetons d'API) —
|
||||
honoré uniquement sur `/mcp` et `/api/`, jamais sur le web.
|
||||
|
||||
## Activer le temps réel (webhook Gitea)
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Émission d'événements vers le journal du service d'agents (Lot 2).
|
||||
|
||||
Kanbante émet ses changements de colonne au journal d'activité
|
||||
cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON
|
||||
``{id, type, occurred_at, actor, domain, source_app,
|
||||
object{ref, label, url}}`` signée HMAC-SHA256 du corps brut
|
||||
(en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante
|
||||
(``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom
|
||||
d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``).
|
||||
|
||||
Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais
|
||||
d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan,
|
||||
timeout court, exceptions avalées avec un avertissement au journal
|
||||
local. L'appel sortant suit le patron de la maison (httpx, voir
|
||||
``briefing.send_ntfy``).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante")
|
||||
|
||||
SOURCE_APP = "kanbante"
|
||||
DELAI_S = 2.0
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
"""True quand l'émission est configurée (clé propre posée)."""
|
||||
return bool(get_settings().agent_evenements_cle)
|
||||
|
||||
|
||||
def construire_enveloppe(
|
||||
type_: str,
|
||||
ref: str,
|
||||
label: str,
|
||||
url: str,
|
||||
domain: str = "pro",
|
||||
actor: str = "user",
|
||||
) -> dict:
|
||||
"""L'enveloppe ACC-0.2 : une référence et un libellé, jamais le
|
||||
contenu de l'objet."""
|
||||
return {
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": type_,
|
||||
"occurred_at": datetime.now(timezone.utc).isoformat(),
|
||||
"actor": actor,
|
||||
"domain": domain,
|
||||
"source_app": SOURCE_APP,
|
||||
"object": {"ref": ref, "label": label, "url": url},
|
||||
}
|
||||
|
||||
|
||||
def signer(corps: bytes, cle: str) -> str:
|
||||
"""Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut."""
|
||||
return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _poster(url: str, corps: bytes, signature: str) -> bool:
|
||||
"""POST l'enveloppe signée ; False (et un avertissement) sur
|
||||
tout échec — le récepteur en panne ne doit rien casser ici."""
|
||||
try:
|
||||
reponse = httpx.post(
|
||||
url,
|
||||
content=corps,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Draco-Signature": signature,
|
||||
},
|
||||
timeout=DELAI_S,
|
||||
)
|
||||
if reponse.status_code >= 300:
|
||||
logger.warning(
|
||||
"Journal agent : réponse %s de %s", reponse.status_code, url
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning("Journal agent injoignable (%s) : %s", url, exc)
|
||||
return False
|
||||
|
||||
|
||||
def emettre(
|
||||
type_: str,
|
||||
ref: str,
|
||||
label: str,
|
||||
url: str,
|
||||
domain: str = "pro",
|
||||
actor: str = "user",
|
||||
) -> threading.Thread | None:
|
||||
"""Émettre un événement, au mieux, depuis un fil d'arrière-plan.
|
||||
|
||||
Retourne le fil lancé (les tests peuvent le joindre), ou None
|
||||
quand l'émission n'est pas configurée. Ne lève jamais.
|
||||
"""
|
||||
settings = get_settings()
|
||||
cle = settings.agent_evenements_cle
|
||||
if not cle:
|
||||
return None
|
||||
enveloppe = construire_enveloppe(
|
||||
type_, ref, label, url, domain=domain, actor=actor
|
||||
)
|
||||
corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8")
|
||||
fil = threading.Thread(
|
||||
target=_poster,
|
||||
args=(settings.agent_evenements_url, corps, signer(corps, cle)),
|
||||
name="kanbante-agent-journal",
|
||||
daemon=True,
|
||||
)
|
||||
fil.start()
|
||||
return fil
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Colonnes de board personnalisables par espace (0.30.0).
|
||||
|
||||
Avant : six colonnes codées en dur (backlog, todo, in_progress,
|
||||
qa, review, done) et un mapping fixe colonne → étiquette
|
||||
``kanban:<colonne>``. Maintenant, chaque espace de travail
|
||||
définit ses colonnes : nom d'affichage, couleur, ordre,
|
||||
étiquette de forge mappée et alias reconnus. Les clés sont
|
||||
immuables (les données board_state les référencent) ; backlog
|
||||
et done restent des colonnes système (renommables, non
|
||||
supprimables) parce qu'elles portent une sémantique : défaut
|
||||
des cartes neuves, et état fermé des issues.
|
||||
|
||||
Sans définition propre, un espace retombe sur les défauts
|
||||
historiques — comportement 0.24 strictement conservé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app import models
|
||||
|
||||
DEFAULT_COLUMNS: list[dict] = [
|
||||
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
|
||||
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
|
||||
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
|
||||
"label": "kanban:todo", "aliases": "", "kind": "flow",
|
||||
"in_focus": 0},
|
||||
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
|
||||
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
|
||||
"label": "kanban:qa", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
|
||||
"label": "kanban:review", "aliases": "", "kind": "flow",
|
||||
"in_focus": 1},
|
||||
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
|
||||
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
|
||||
]
|
||||
|
||||
_cache: dict[int, list[dict]] = {}
|
||||
|
||||
|
||||
def invalidate(workspace_id: int | None = None) -> None:
|
||||
if workspace_id is None:
|
||||
_cache.clear()
|
||||
else:
|
||||
_cache.pop(workspace_id, None)
|
||||
|
||||
|
||||
def ensure_columns(workspace_id: int) -> list[dict]:
|
||||
"""Seed the historical defaults if the workspace has none."""
|
||||
existing = models.list_board_columns(workspace_id)
|
||||
if existing:
|
||||
return existing
|
||||
for position, column in enumerate(DEFAULT_COLUMNS):
|
||||
models.upsert_board_column(
|
||||
workspace_id, {**column, "position": position}
|
||||
)
|
||||
invalidate(workspace_id)
|
||||
return models.list_board_columns(workspace_id)
|
||||
|
||||
|
||||
def get_columns(workspace_id: int = 1) -> list[dict]:
|
||||
"""Effective columns of a workspace (cached, seeded lazily)."""
|
||||
if workspace_id not in _cache:
|
||||
_cache[workspace_id] = ensure_columns(workspace_id)
|
||||
return _cache[workspace_id]
|
||||
|
||||
|
||||
def keys(workspace_id: int = 1) -> list[str]:
|
||||
return [c["key"] for c in get_columns(workspace_id)]
|
||||
|
||||
|
||||
def names(workspace_id: int = 1) -> dict[str, str]:
|
||||
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
|
||||
|
||||
|
||||
def colors(workspace_id: int = 1) -> dict[str, str]:
|
||||
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
|
||||
|
||||
|
||||
def flow_keys(workspace_id: int = 1) -> list[str]:
|
||||
return [
|
||||
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
|
||||
]
|
||||
|
||||
|
||||
def focus_keys(workspace_id: int = 1) -> list[str]:
|
||||
return [
|
||||
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
|
||||
]
|
||||
|
||||
|
||||
def wall_keys(workspace_id: int = 1) -> list[str]:
|
||||
"""Wallboard cards: focus columns + the first flow column."""
|
||||
columns = get_columns(workspace_id)
|
||||
wanted = {c["key"] for c in columns if c.get("in_focus")}
|
||||
flow = [c["key"] for c in columns if c["kind"] == "flow"]
|
||||
if flow:
|
||||
wanted.add(flow[0])
|
||||
return [c["key"] for c in columns if c["key"] in wanted]
|
||||
|
||||
|
||||
def label_for(workspace_id: int, key: str) -> str | None:
|
||||
"""The forge label mirrored when a card enters this column."""
|
||||
for column in get_columns(workspace_id):
|
||||
if column["key"] == key and column["kind"] == "flow":
|
||||
return column.get("label") or None
|
||||
return None
|
||||
|
||||
|
||||
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
|
||||
"""label name (lowercased) → column key, aliases included.
|
||||
|
||||
This is the configurable label/column mapping: the canonical
|
||||
label of each flow column plus every alias declared for it
|
||||
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
|
||||
"""
|
||||
lookup: dict[str, str] = {}
|
||||
for column in get_columns(workspace_id):
|
||||
if column["kind"] != "flow":
|
||||
continue
|
||||
names = [column.get("label") or ""]
|
||||
names += [
|
||||
a.strip() for a in (column.get("aliases") or "").split(",")
|
||||
]
|
||||
for name in names:
|
||||
name = name.strip().lower()
|
||||
if name:
|
||||
lookup[name] = column["key"]
|
||||
return lookup
|
||||
|
||||
|
||||
def recognized_labels(workspace_id: int = 1) -> set[str]:
|
||||
"""Every label name (lowercased) owned by the mapping."""
|
||||
return set(label_lookup(workspace_id))
|
||||
+17
-1
@@ -96,6 +96,15 @@ Environment variables:
|
||||
KANBANTE_ZOMBIE_DAYS Days without any activity after
|
||||
which an open card is a "zombie"
|
||||
on /health (issue #9). Default 30.
|
||||
AGENT_EVENEMENTS_URL Journal endpoint of the agent
|
||||
service (Lot 2, contract ACC-0.2)
|
||||
receiving this app's signed events.
|
||||
Default https://agent.dracodev.net/api/v1/evenements.
|
||||
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
|
||||
events it emits to that journal (the
|
||||
service knows it as the "kanbante"
|
||||
entry of AGENT_EVENEMENTS_CLES).
|
||||
Empty (default) = emission disabled.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -104,7 +113,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.24.1"
|
||||
APP_VERSION = "0.31.1"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -180,6 +189,8 @@ class Settings:
|
||||
sso_machine_key: str = ""
|
||||
sso_url: str = "https://og.dracodev.net"
|
||||
archive_suggest_days: int = 180
|
||||
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
|
||||
agent_evenements_cle: str = ""
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -238,6 +249,11 @@ def _env_settings() -> Settings:
|
||||
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
||||
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
||||
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
|
||||
agent_evenements_url=os.getenv(
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
"https://agent.dracodev.net/api/v1/evenements",
|
||||
).rstrip("/"),
|
||||
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.24.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.31.1"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ class GiteaClient:
|
||||
"""
|
||||
|
||||
kind = "gitea"
|
||||
supports_attachments = True
|
||||
api_suffix = "/api/v1"
|
||||
auth_scheme = "token"
|
||||
|
||||
@@ -503,6 +504,87 @@ class GiteaClient:
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def update_issue(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict:
|
||||
"""Edit an issue's title and/or body (0.31.0)."""
|
||||
payload: dict = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if not payload:
|
||||
return await self.get_issue(owner, repo, number)
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}/issues/{number}", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
# ── Pièces jointes des issues (0.31.0) ────────────────
|
||||
|
||||
async def list_issue_assets(
|
||||
self, owner: str, repo: str, number: int
|
||||
) -> list[dict]:
|
||||
"""Attachments of an issue (Gitea assets)."""
|
||||
return await self._get(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/assets"
|
||||
)
|
||||
|
||||
async def upload_issue_asset(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
filename: str,
|
||||
content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> dict:
|
||||
"""Attach a file to an issue; returns the asset record."""
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/assets",
|
||||
files={"attachment": (filename, content, content_type)},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def delete_issue_asset(
|
||||
self, owner: str, repo: str, number: int, asset_id: int
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def download_issue_asset(
|
||||
self, owner: str, repo: str, number: int, asset_id: int
|
||||
) -> tuple[bytes, str]:
|
||||
"""(content, content_type) of one attachment.
|
||||
|
||||
Gitea nuance (vérifiée en réel sur 1.27.3): GET on the
|
||||
asset endpoint returns its JSON record, NOT the bytes —
|
||||
the file itself lives at `browser_download_url`
|
||||
(/attachments/<uuid>), fetched here with the connection's
|
||||
token so private repos work too.
|
||||
"""
|
||||
record = await self._get(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
|
||||
)
|
||||
url = record.get("browser_download_url") if isinstance(record, dict) else None
|
||||
if not url:
|
||||
raise ValueError("La forge ne fournit pas d'URL de téléchargement")
|
||||
r = await self.client.get(url)
|
||||
r.raise_for_status()
|
||||
return r.content, r.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
async def set_issue_state(
|
||||
self, owner: str, repo: str, issue_number: int, state: str
|
||||
) -> dict:
|
||||
|
||||
@@ -27,6 +27,10 @@ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
|
||||
|
||||
class GitHubClient(GiteaClient):
|
||||
kind = "github"
|
||||
# L'API GitHub ne sait pas joindre de fichiers aux issues
|
||||
# (réservé à son interface web) : les routes Kanbante
|
||||
# répondent proprement « non pris en charge » (0.31.0).
|
||||
supports_attachments = False
|
||||
api_suffix = ""
|
||||
auth_scheme = "Bearer"
|
||||
|
||||
|
||||
+845
-103
File diff suppressed because it is too large
Load Diff
+216
-7
@@ -11,8 +11,10 @@ and upgrades older databases in place (position column, notes
|
||||
updated_at, cleanup of rows carrying an unknown column name).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
@@ -134,6 +136,16 @@ def init_db() -> None:
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
@@ -142,6 +154,21 @@ def init_db() -> None:
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ── Colonnes personnalisables par espace (0.30.0) ──
|
||||
CREATE TABLE IF NOT EXISTS board_columns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
aliases TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'flow',
|
||||
in_focus INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(workspace_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_connections (
|
||||
workspace_id INTEGER NOT NULL,
|
||||
connection_id INTEGER NOT NULL,
|
||||
@@ -207,7 +234,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
# get_board_state() with a KeyError. Drop them once, here.
|
||||
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
|
||||
conn.execute(
|
||||
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})",
|
||||
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders}) "
|
||||
"AND NOT EXISTS (SELECT 1 FROM board_columns bc "
|
||||
"WHERE bc.workspace_id = board_state.workspace_id "
|
||||
"AND bc.key = board_state.column_name)",
|
||||
KANBAN_COLUMNS,
|
||||
)
|
||||
|
||||
@@ -328,7 +358,9 @@ def get_board_state(
|
||||
(workspace_id, connection_id, repo_full_name),
|
||||
).fetchall()
|
||||
|
||||
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||
columns: dict[str, list[dict]] = {
|
||||
col: [] for col in column_keys(workspace_id)
|
||||
}
|
||||
for row in rows:
|
||||
column = row["column_name"]
|
||||
if column not in columns:
|
||||
@@ -352,7 +384,7 @@ def move_issue(
|
||||
connection_id: int = 1,
|
||||
) -> None:
|
||||
"""Move an issue to a different column (appended at the end)."""
|
||||
if to_column not in KANBAN_COLUMNS:
|
||||
if to_column not in column_keys(workspace_id):
|
||||
raise ValueError(f"Invalid column: {to_column}")
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
@@ -388,7 +420,7 @@ def set_column_order(
|
||||
its index in the list. Used after a drag & drop so manual ordering
|
||||
survives reloads.
|
||||
"""
|
||||
if column_name not in KANBAN_COLUMNS:
|
||||
if column_name not in column_keys(workspace_id):
|
||||
raise ValueError(f"Invalid column: {column_name}")
|
||||
with get_db() as conn:
|
||||
for index, issue_number in enumerate(ordered_issue_numbers):
|
||||
@@ -571,7 +603,7 @@ def latest_snapshot_at() -> str | None:
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule")
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited")
|
||||
|
||||
|
||||
def log_event(
|
||||
@@ -781,11 +813,10 @@ def get_stats() -> dict:
|
||||
|
||||
projects: dict[str, dict[str, int]] = {}
|
||||
for row in rows:
|
||||
if row["column_name"] not in KANBAN_COLUMNS:
|
||||
continue
|
||||
name = row["repo_full_name"]
|
||||
if name not in projects:
|
||||
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
|
||||
# Les colonnes personnalisées (0.30.0) s'ajoutent au vol.
|
||||
projects[name][row["column_name"]] = row["cnt"]
|
||||
return projects
|
||||
|
||||
@@ -900,6 +931,73 @@ def get_user_by_username(username: str) -> dict | None:
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# --- Jetons d'API (0.25.0) -------------------------------------------------
|
||||
# Jetons personnels pour les consommateurs machine (le service d'agents
|
||||
# et son client MCP, en premier lieu). Le texte clair (préfixe kbt_)
|
||||
# n'existe qu'à la création ; la base ne garde que l'empreinte SHA-256.
|
||||
# Un jeton authentifie uniquement les surfaces machine (/mcp, /api/) —
|
||||
# jamais l'interface web.
|
||||
|
||||
API_TOKEN_PREFIX = "kbt_"
|
||||
|
||||
|
||||
def _hash_api_token(plaintext: str) -> str:
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_api_token(user_id: int, name: str) -> tuple[dict, str]:
|
||||
plaintext = API_TOKEN_PREFIX + secrets.token_urlsafe(32)
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)",
|
||||
(user_id, name.strip(), _hash_api_token(plaintext)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM api_tokens WHERE id = ?", (cur.lastrowid,)
|
||||
).fetchone()
|
||||
return dict(row), plaintext
|
||||
|
||||
|
||||
def list_api_tokens(user_id: int) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
return [
|
||||
dict(r)
|
||||
for r in conn.execute(
|
||||
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY id",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
|
||||
def revoke_api_token(user_id: int, token_id: int) -> bool:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at = datetime('now')"
|
||||
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
||||
(token_id, user_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def user_by_api_token(plaintext: str) -> dict | None:
|
||||
if not plaintext or not plaintext.startswith(API_TOKEN_PREFIX):
|
||||
return None
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id"
|
||||
" WHERE t.token_hash = ? AND t.revoked_at IS NULL AND u.active = 1",
|
||||
(_hash_api_token(plaintext),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET last_used_at = datetime('now')"
|
||||
" WHERE token_hash = ?",
|
||||
(_hash_api_token(plaintext),),
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
def get_user_by_draco(sub: str) -> dict | None:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
@@ -1174,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Colonnes de board personnalisées (0.30.0) ─────────────
|
||||
# Définitions PAR ESPACE : clé immuable, nom/couleur libres,
|
||||
# étiquette forge mappée (+ alias reconnus à l'adoption).
|
||||
# Sans lignes pour un espace, column_keys retombe sur les
|
||||
# défauts historiques — comportement 0.24 strictement conservé.
|
||||
|
||||
def list_board_columns(workspace_id: int) -> list[dict]:
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM board_columns WHERE workspace_id = ? "
|
||||
"ORDER BY position, id",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def column_keys(workspace_id: int = 1) -> list[str]:
|
||||
"""Ordered column keys of a workspace (defaults if unset)."""
|
||||
cols = list_board_columns(workspace_id)
|
||||
if not cols:
|
||||
return list(KANBAN_COLUMNS)
|
||||
return [c["key"] for c in cols]
|
||||
|
||||
|
||||
def upsert_board_column(workspace_id: int, column: dict) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO board_columns
|
||||
(workspace_id, key, name, color, position, label,
|
||||
aliases, kind, in_focus)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id, key) DO UPDATE SET
|
||||
name = excluded.name, color = excluded.color,
|
||||
position = excluded.position, label = excluded.label,
|
||||
aliases = excluded.aliases, kind = excluded.kind,
|
||||
in_focus = excluded.in_focus""",
|
||||
(
|
||||
workspace_id, column["key"], column["name"],
|
||||
column.get("color", "#6b7280"),
|
||||
int(column.get("position", 0)),
|
||||
column.get("label", ""), column.get("aliases", ""),
|
||||
column.get("kind", "flow"),
|
||||
1 if column.get("in_focus") else 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_board_column(workspace_id: int, key: str) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM board_columns WHERE workspace_id = ? AND key = ?",
|
||||
(workspace_id, key),
|
||||
)
|
||||
|
||||
|
||||
def clear_board_columns(workspace_id: int) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM board_columns WHERE workspace_id = ?",
|
||||
(workspace_id,),
|
||||
)
|
||||
|
||||
|
||||
def count_column_cards(workspace_id: int, key: str) -> int:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM board_state "
|
||||
"WHERE workspace_id = ? AND column_name = ?",
|
||||
(workspace_id, key),
|
||||
).fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
|
||||
def migrate_column_cards(
|
||||
workspace_id: int, from_key: str, to_key: str
|
||||
) -> int:
|
||||
"""Move every card of a column to another (column deletion).
|
||||
|
||||
Positions are appended after the target column's current
|
||||
maximum, per repository and connection.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT connection_id, repo_full_name, issue_number "
|
||||
"FROM board_state WHERE workspace_id = ? AND column_name = ? "
|
||||
"ORDER BY position, issue_number",
|
||||
(workspace_id, from_key),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
nxt = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 AS p FROM board_state "
|
||||
"WHERE workspace_id = ? AND connection_id = ? "
|
||||
"AND repo_full_name = ? AND column_name = ?",
|
||||
(
|
||||
workspace_id, row["connection_id"],
|
||||
row["repo_full_name"], to_key,
|
||||
),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"UPDATE board_state SET column_name = ?, position = ?, "
|
||||
"updated_at = datetime('now') WHERE workspace_id = ? "
|
||||
"AND connection_id = ? AND repo_full_name = ? "
|
||||
"AND issue_number = ?",
|
||||
(
|
||||
to_key, nxt["p"], workspace_id, row["connection_id"],
|
||||
row["repo_full_name"], row["issue_number"],
|
||||
),
|
||||
)
|
||||
return len(rows)
|
||||
|
||||
+4
-1
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
|
||||
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from app.models import KANBAN_COLUMNS
|
||||
|
||||
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
|
||||
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
|
||||
"(fermer/déplacer une carte par règle est interdit en v1)"
|
||||
)
|
||||
continue
|
||||
if "column" in when and when["column"] not in KANBAN_COLUMNS:
|
||||
if "column" in when and when["column"] not in (
|
||||
set(KANBAN_COLUMNS) | set(models.column_keys(1))
|
||||
):
|
||||
errors.append(f"{name} : colonne inconnue « {when['column']} »")
|
||||
continue
|
||||
if "min_age_days" in when and (
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
Inclus par _header.html (toutes les pages applicatives ; ni le
|
||||
wallboard ni l'écran de connexion, volontaires). #}
|
||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ version }}">
|
||||
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ version }}">
|
||||
<script>document.body.classList.add('has-draco-bar');</script>
|
||||
<header class="draco-bar" data-current="kanbante">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
@@ -86,7 +87,11 @@
|
||||
if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||
if (act === "spark") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
return;
|
||||
}
|
||||
if (act === "rappel-entete") {
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
if (bar) bar.style.display = "";
|
||||
@@ -96,3 +101,4 @@
|
||||
};
|
||||
</script>
|
||||
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
|
||||
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
("admin", "/admin", "🛠️", "Vue d'ensemble"),
|
||||
("admin_connections", "/admin/connections", "🔌", "Connexions"),
|
||||
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
|
||||
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
|
||||
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
|
||||
]),
|
||||
] %}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Colonnes — Administration — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
<div class="page-head">
|
||||
<h1>🧱 Colonnes du board</h1>
|
||||
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
|
||||
</div>
|
||||
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>Comment marche le mapping</h2>
|
||||
<p class="notes-hint">
|
||||
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
|
||||
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
|
||||
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
|
||||
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{% for c in columns %}
|
||||
<section class="briefing-card">
|
||||
<h2>
|
||||
<span class="ws-dot" style="background: {{ c.color }}"></span>
|
||||
{{ c.name }}
|
||||
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
|
||||
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
|
||||
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
|
||||
</h2>
|
||||
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
|
||||
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
|
||||
{% if c.kind == 'flow' %}
|
||||
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
|
||||
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
|
||||
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||
</form>
|
||||
<p class="admin-actions">
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="direction" value="up">
|
||||
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="direction" value="down">
|
||||
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
|
||||
</form>
|
||||
{% if c.kind == 'flow' %}
|
||||
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Déplacer ses cartes vers
|
||||
<select name="target">
|
||||
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</p>
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>➕ Nouvelle colonne</h2>
|
||||
<form method="post" action="/admin/columns" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
|
||||
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
|
||||
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
|
||||
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>↩️ Réinitialiser</h2>
|
||||
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
|
||||
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,11 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>{{ repo }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||
<script src="/static/vendor/sortable.min.js"></script>
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
<script src="/static/js/board.js?v={{ version }}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{% include "_header.html" %}
|
||||
@@ -53,16 +53,31 @@
|
||||
</select>
|
||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
|
||||
<form id="create-issue-form">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
|
||||
<form id="create-issue-form" class="composer">
|
||||
<div class="composer-head">
|
||||
<span class="composer-title">➕ Nouvelle issue</span>
|
||||
<span class="composer-target">arrive dans
|
||||
<strong>{{ column_labels.backlog }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div class="composer-main">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="Titre de l'issue..." required>
|
||||
<button type="submit" class="btn-save btn-inline">Créer l'issue</button>
|
||||
</div>
|
||||
<details class="composer-desc" id="new-issue-desc">
|
||||
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
|
||||
<textarea id="new-issue-body" rows="5" maxlength="100000"
|
||||
placeholder="Description (markdown)..."></textarea>
|
||||
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
|
||||
</details>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<main class="board-container">
|
||||
<div class="kanban-board">
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% for col in column_keys %}
|
||||
<div class="kanban-column"
|
||||
data-column="{{ col }}">
|
||||
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
|
||||
@@ -145,6 +160,24 @@
|
||||
</div>
|
||||
<div id="detail-code" class="detail-code"></div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<div class="detail-actions">
|
||||
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
|
||||
</div>
|
||||
<form id="detail-edit-form" hidden>
|
||||
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
|
||||
<textarea id="detail-body-input" rows="10" maxlength="100000"
|
||||
placeholder="Description (markdown)..."></textarea>
|
||||
<div class="detail-actions">
|
||||
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
|
||||
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
|
||||
<div id="detail-assets"></div>
|
||||
<form id="asset-form" class="asset-form">
|
||||
<input type="file" id="asset-file">
|
||||
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
|
||||
</form>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<div id="detail-comments"></div>
|
||||
<form id="comment-form">
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
{% endif %}
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% for col in column_keys %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||
|
||||
@@ -46,6 +46,43 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>🗝️ Jetons d'API (MCP)</h2>
|
||||
<p class="muted">Pour les consommateurs machine — le service d'agents, en premier lieu. Un jeton (préfixe <code>kbt_</code>) authentifie uniquement <code>/mcp</code> et <code>/api/</code>, jamais l'interface web. Il se présente en en-tête <code>Authorization: Bearer kbt_…</code>. Le texte clair n'est affiché qu'à la création ; ensuite, seule son empreinte est conservée.</p>
|
||||
{% if profile_user.id %}
|
||||
{% if nouveau_jeton %}
|
||||
<p>✅ Nouveau jeton — <strong>copie-le maintenant</strong> :</p>
|
||||
<p><code style="font-size:15px">{{ nouveau_jeton }}</code></p>
|
||||
{% endif %}
|
||||
{% if api_tokens %}
|
||||
<ul>
|
||||
{% for t in api_tokens %}
|
||||
<li>
|
||||
<strong>{{ t.name }}</strong> · créé le {{ t.created_at }}
|
||||
· {% if t.last_used_at %}dernier usage : {{ t.last_used_at }}{% else %}jamais utilisé{% endif %}
|
||||
{% if t.revoked_at %} · <span class="chip-warn">révoqué le {{ t.revoked_at }}</span>
|
||||
{% else %}
|
||||
<form method="post" action="/profile/tokens/{{ t.id }}/revoke" class="inline-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-mini">Révoquer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Aucun jeton pour l'instant.</p>
|
||||
{% endif %}
|
||||
<form method="post" action="/profile/tokens" class="admin-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Nom du jeton <input type="text" name="name" placeholder="service-agents" required></label>
|
||||
<button type="submit" class="btn-save btn-inline">Créer un jeton</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">Compte défini par l'environnement (KANBANTE_AUTH_*) : les jetons appartiennent aux comptes de la base — connecte-toi avec ton compte personnel pour en créer un.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>✦ Liaison draco (SSO)</h2>
|
||||
{% if profile_user.draco_sub %}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.24.0"
|
||||
version = "0.31.1"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||
partagent donc toujours le même mode. */
|
||||
.draco-panneau {
|
||||
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(105%); transition: transform .28s ease;
|
||||
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
.draco-panneau[data-draco-theme="dark"] {
|
||||
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||
--p-lien: #b39df5;
|
||||
}
|
||||
.draco-panneau.open { transform: translateX(0); }
|
||||
.draco-poignee {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||
}
|
||||
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-disc-bar .d-titre {
|
||||
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-disc-bar .d-btn {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||
}
|
||||
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-discussions {
|
||||
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||
}
|
||||
.draco-discussions .d-section {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||
}
|
||||
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||
.draco-discussions .d-item {
|
||||
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.draco-discussions .d-actions button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||
}
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||
border-radius: 12px; padding: 10px 12px;
|
||||
}
|
||||
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||
.draco-appro .resume { margin: 5px 0 8px; }
|
||||
.draco-appro .btns { display: flex; gap: 7px; }
|
||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||
.draco-auj h5:first-child { margin-top: 0; }
|
||||
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||
.draco-panneau-form { display: flex; gap: 8px; }
|
||||
.draco-panneau-form input {
|
||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-panneau-form button {
|
||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||
.draco-msg.md code, .m-rapport.md code {
|
||||
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
.draco-msg.md pre, .m-rapport.md pre {
|
||||
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||
overflow-x: auto; margin: 7px 0;
|
||||
}
|
||||
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||
color: var(--p-doux);
|
||||
}
|
||||
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||
background: var(--p-chip);
|
||||
}
|
||||
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||
.draco-msg.md table, .m-rapport.md table {
|
||||
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||
display: block; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||
}
|
||||
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||
@@ -60,6 +60,28 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
button, input, select, textarea { font: inherit; }
|
||||
|
||||
/* — Base des champs de saisie (#29) : sans règle de fond, tout
|
||||
champ hors d'un contexte stylé (.login-form, .capture-form,
|
||||
.admin-form…) gardait le fond BLANC du navigateur — vu sur la
|
||||
réinitialisation de mot de passe (Administration), la sélection
|
||||
de projets (Espaces, textarea jamais couverte) et les valeurs
|
||||
du panneau (Paramètres). Sélecteur en :where() = spécificité
|
||||
zéro : cette base ne peut écraser AUCUNE règle de contexte
|
||||
existante, elle ne fait que combler les vides. */
|
||||
:root { color-scheme: dark; }
|
||||
:where(input:not([type="checkbox"]):not([type="radio"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="reset"]):not([type="image"]):not([type="range"]),
|
||||
textarea, select) {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
:where(input, textarea, select):focus-visible {
|
||||
outline: 2px solid rgba(124, 92, 252, 0.55);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
/* ── App header ─────────────────────────────── */
|
||||
.app-header, header {
|
||||
position: sticky;
|
||||
@@ -1661,3 +1683,34 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
||||
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
||||
|
||||
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
|
||||
.kanban-card { cursor: pointer; }
|
||||
.detail-actions { margin: 10px 0; }
|
||||
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
|
||||
#detail-edit-form textarea { width: 100%; }
|
||||
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
|
||||
.asset-row a { text-decoration: none; font-weight: 600; }
|
||||
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
|
||||
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Compositeur de nouvelle issue (0.31.1) ── */
|
||||
/* La toolbar impose display:flex à ses formulaires : le
|
||||
compositeur est une carte, en bloc et pleine largeur. */
|
||||
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
|
||||
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
|
||||
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||
.composer-title { font-weight: 700; font-size: 0.95rem; }
|
||||
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
|
||||
.composer-main { display: flex; gap: 8px; }
|
||||
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
|
||||
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-desc { margin-top: 10px; }
|
||||
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
|
||||
.composer-desc summary::-webkit-details-marker { display: none; }
|
||||
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
|
||||
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
|
||||
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
|
||||
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
|
||||
|
||||
+153
-6
@@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('repo_full_name', repoFullName);
|
||||
formData.append('title', title);
|
||||
const bodyInput = document.getElementById('new-issue-body');
|
||||
if (bodyInput && bodyInput.value.trim()) {
|
||||
formData.append('body', bodyInput.value.trim());
|
||||
}
|
||||
formData.append('column', 'backlog');
|
||||
formData.append('csrf_token', csrfToken);
|
||||
try {
|
||||
@@ -118,6 +122,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
createForm?.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createForm.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
// The "Glissez une carte ici" hint is rendered by the server only
|
||||
// for columns empty at page load. After a drag, the DOM is the
|
||||
// truth: remove the hint from columns that hold cards, restore it
|
||||
@@ -323,14 +334,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// ── Card detail drawer ─────────────────────────────────
|
||||
// A click opens the panel; a drag must not. Track pointer travel.
|
||||
// DELEGATED on purpose (0.31.0): the live board swaps card
|
||||
// fragments in place — listeners attached per card died at the
|
||||
// first refresh, which made cards "hard to click". One listener
|
||||
// on the document keeps working whatever the DOM becomes.
|
||||
let downX = 0, downY = 0;
|
||||
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
|
||||
document.querySelectorAll('.kanban-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
|
||||
if (e.target.closest('a')) return;
|
||||
openDetail(card.dataset.repo, card.dataset.issue);
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
|
||||
if (e.target.closest('a') || e.target.closest('button')) return;
|
||||
const card = e.target.closest('.kanban-card');
|
||||
if (card) openDetail(card.dataset.repo, card.dataset.issue);
|
||||
});
|
||||
|
||||
const drawer = document.getElementById('card-detail');
|
||||
@@ -355,6 +369,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
overlay?.addEventListener('click', closeDetail);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
|
||||
|
||||
let currentComments = [];
|
||||
let currentIssueData = null;
|
||||
|
||||
async function openDetail(repoFull, number) {
|
||||
const [owner, repo] = repoFull.split('/', 2);
|
||||
currentIssue = { repo: repoFull, number };
|
||||
@@ -368,6 +385,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
currentComments = data.comments || [];
|
||||
currentIssueData = data.issue;
|
||||
fillDetail(data.issue, data.comments);
|
||||
loadCodeLinks(repoFull, number);
|
||||
} catch (err) {
|
||||
@@ -432,6 +451,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
function fillDetail(issue, comments) {
|
||||
currentIssueData = issue;
|
||||
document.getElementById('detail-title').textContent = issue.title;
|
||||
const stateChip = document.getElementById('detail-state');
|
||||
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
|
||||
@@ -457,6 +477,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
renderLabelToggles(issue);
|
||||
document.getElementById('detail-body').innerHTML =
|
||||
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
|
||||
renderAssets(issue);
|
||||
setEditMode(false);
|
||||
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
|
||||
const box = document.getElementById('detail-comments');
|
||||
box.innerHTML = '';
|
||||
@@ -520,6 +542,131 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setEditMode(on) {
|
||||
const form = document.getElementById('detail-edit-form');
|
||||
const btn = document.getElementById('detail-edit-btn');
|
||||
const title = document.getElementById('detail-title');
|
||||
const body = document.getElementById('detail-body');
|
||||
if (!form || !btn) return;
|
||||
form.hidden = !on;
|
||||
btn.hidden = on;
|
||||
if (title) title.hidden = on;
|
||||
if (body) body.hidden = on;
|
||||
if (on && currentIssueData) {
|
||||
document.getElementById('detail-title-input').value = currentIssueData.title || '';
|
||||
document.getElementById('detail-body-input').value = currentIssueData.body || '';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('detail-edit-btn')?.addEventListener('click', () => setEditMode(true));
|
||||
document.getElementById('detail-edit-cancel')?.addEventListener('click', () => setEditMode(false));
|
||||
document.getElementById('detail-edit-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentIssue) return;
|
||||
const title = document.getElementById('detail-title-input').value.trim();
|
||||
const body = document.getElementById('detail-body-input').value;
|
||||
if (!title) { showToast('❌ Le titre ne peut pas être vide', true); return; }
|
||||
const [owner, repo] = currentIssue.repo.split('/', 2);
|
||||
const fd = new FormData();
|
||||
fd.append('title', title);
|
||||
fd.append('body', body);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/update`,
|
||||
{ method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
fillDetail(data.issue, currentComments);
|
||||
const card = document.querySelector(`.kanban-card[data-issue="${currentIssue.number}"]`);
|
||||
if (card) {
|
||||
const t = card.querySelector('.card-title');
|
||||
if (t) t.textContent = data.issue.title;
|
||||
card.dataset.title = (data.issue.title || '').toLowerCase();
|
||||
}
|
||||
showToast('✅ Issue mise à jour');
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
|
||||
function humanSize(bytes) {
|
||||
if (!bytes) return '0 o';
|
||||
const units = ['o', 'Kio', 'Mio', 'Gio'];
|
||||
let i = 0, v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function renderAssets(issue) {
|
||||
const box = document.getElementById('detail-assets');
|
||||
const form = document.getElementById('asset-form');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
if (!issue.attachments_supported) {
|
||||
box.innerHTML = '<p class="detail-empty">Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.</p>';
|
||||
if (form) form.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (form) form.hidden = false;
|
||||
const assets = issue.assets || [];
|
||||
if (!assets.length) {
|
||||
box.innerHTML = '<p class="detail-empty">Aucune pièce jointe.</p>';
|
||||
return;
|
||||
}
|
||||
const [owner, repo] = (currentIssue?.repo || '/').split('/', 2);
|
||||
assets.forEach(a => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'asset-row';
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/download`;
|
||||
link.textContent = '📎 ' + a.name;
|
||||
link.title = 'Télécharger';
|
||||
row.appendChild(link);
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'asset-meta';
|
||||
meta.textContent = humanSize(a.size) + (a.created_at ? ' · ' + String(a.created_at).slice(0, 10) : '');
|
||||
row.appendChild(meta);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn-mini btn-danger';
|
||||
del.textContent = 'Supprimer';
|
||||
del.addEventListener('click', async () => {
|
||||
if (!confirm('Supprimer la pièce jointe « ' + a.name + ' » ?')) return;
|
||||
const fd = new FormData();
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/delete`,
|
||||
{ method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
showToast('✅ Pièce jointe supprimée');
|
||||
openDetail(currentIssue.repo, currentIssue.number);
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
row.appendChild(del);
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('asset-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentIssue) return;
|
||||
const input = document.getElementById('asset-file');
|
||||
const file = input?.files?.[0];
|
||||
if (!file) { showToast('❌ Choisis un fichier d\'abord', true); return; }
|
||||
const [owner, repo] = currentIssue.repo.split('/', 2);
|
||||
const fd = new FormData();
|
||||
fd.append('attachment', file);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets`,
|
||||
{ method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
input.value = '';
|
||||
showToast('✅ Fichier joint : ' + data.asset.name);
|
||||
openDetail(currentIssue.repo, currentIssue.number);
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
|
||||
function commentNode(c) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||
*
|
||||
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||
* GET /api/v1/me — identité (cookie draco_session)
|
||||
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||
* LLM DeepSeek + outils MCP des apps ;
|
||||
* la réponse arrive avec son HTML
|
||||
* Markdown rendu côté service (v2)
|
||||
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||
* principale perpétuelle + des
|
||||
* parallèles créées/archivées/
|
||||
* supprimées par l'utilisateur
|
||||
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||
* messages agent portent leur html)
|
||||
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||
* externes J4 : état de configuration)
|
||||
* POST /api/v1/missions — missions longues (J5)
|
||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||
* du même service (agent.dracodev.net) DANS la page, en surimpression
|
||||
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||
* du premier chargement — l'iframe est créé paresseusement et
|
||||
* conservé, la console garde son état entre ouvertures. La barre
|
||||
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||
* surimpression et panneau).
|
||||
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var API = "https://agent.dracodev.net";
|
||||
var convId = null;
|
||||
var convKind = null; // 'principale' | 'parallele'
|
||||
var convTitre = "";
|
||||
var etatConnu = false;
|
||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||
var titreEl = null, tiroirEl = null;
|
||||
|
||||
function domaine() {
|
||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||
}
|
||||
function stockerConv() {
|
||||
try {
|
||||
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||
else sessionStorage.removeItem("draco_panneau_conv");
|
||||
} catch (e) { /* stockage indisponible */ }
|
||||
}
|
||||
function convStockee() {
|
||||
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||
function appHote() {
|
||||
var h = (window.location.hostname || "").split(".")[0];
|
||||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||
kanbante: "kanbante", og: "obsigate" };
|
||||
return correspondance[h] || null;
|
||||
}
|
||||
function urlConsole() {
|
||||
var params = [];
|
||||
var id = convId || convStockee();
|
||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||
var app = appHote();
|
||||
if (app) params.push("app=" + encodeURIComponent(app));
|
||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||
}
|
||||
|
||||
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||
* conservé : rouvrir la console ne le recharge pas. */
|
||||
var surEl = null, surCadre = null, surOuverte = false;
|
||||
var STYLES_CONSOLE =
|
||||
".draco-console-sur{" +
|
||||
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||
"background:var(--p-fond);color:var(--p-texte);" +
|
||||
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||
".draco-console-sur[hidden]{display:none}" +
|
||||
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||
|
||||
function assurerSurimpression() {
|
||||
if (surEl) return;
|
||||
if (!document.getElementById("draco-console-styles")) {
|
||||
var st = document.createElement("style");
|
||||
st.id = "draco-console-styles";
|
||||
st.textContent = STYLES_CONSOLE;
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
surEl = document.createElement("div");
|
||||
surEl.className = "draco-console-sur";
|
||||
surEl.setAttribute("aria-label", "Console Spark");
|
||||
surEl.hidden = true;
|
||||
surEl.innerHTML =
|
||||
'<div class="draco-console-barre">' +
|
||||
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||
' <span class="dc-actions">' +
|
||||
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||
' </span></div>' +
|
||||
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||
document.body.appendChild(surEl);
|
||||
surCadre = surEl.querySelector("iframe");
|
||||
surEl.querySelector('[data-dc="sidebar"]')
|
||||
.addEventListener("click", modeSidebarConsole);
|
||||
surEl.querySelector('[data-dc="fermer"]')
|
||||
.addEventListener("click", fermerAgentConsole);
|
||||
appliquerTheme();
|
||||
}
|
||||
|
||||
function largeurPanneau() {
|
||||
if (root) {
|
||||
var w = root.getBoundingClientRect().width;
|
||||
if (w > 0) return Math.round(w);
|
||||
}
|
||||
return 388;
|
||||
}
|
||||
|
||||
function ouvrirConsole() {
|
||||
assurerSurimpression();
|
||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||
if (surOuverte) return;
|
||||
surOuverte = true;
|
||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||
* extension à toute la page ; le double rAF laisse le temps de
|
||||
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
surEl.hidden = false;
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||
});
|
||||
}
|
||||
|
||||
function retrecirConsole() {
|
||||
if (!surEl || !surOuverte) return;
|
||||
surOuverte = false;
|
||||
var fini = false;
|
||||
function terminer() {
|
||||
if (fini) return;
|
||||
fini = true;
|
||||
surEl.removeEventListener("transitionend", surFin);
|
||||
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||
}
|
||||
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||
surEl.addEventListener("transitionend", surFin);
|
||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||
surEl.style.width = largeurPanneau() + "px";
|
||||
}
|
||||
function modeSidebarConsole() { retrecirConsole(); }
|
||||
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||
|
||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||
function themeHote() {
|
||||
var t = document.documentElement.getAttribute("data-theme");
|
||||
if (t === "dark" || t === "light") return t;
|
||||
if (document.documentElement.classList.contains("dark") ||
|
||||
document.body.classList.contains("dark")) return "dark";
|
||||
try {
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
} catch (e) { /* pas de matchMedia */ }
|
||||
return "light";
|
||||
}
|
||||
function appliquerTheme() {
|
||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||
}
|
||||
function surveillerTheme() {
|
||||
try {
|
||||
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||
window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", appliquerTheme);
|
||||
} catch (e) { /* surveillance facultative */ }
|
||||
}
|
||||
|
||||
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||
function largeurStockee() {
|
||||
try {
|
||||
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||
return isNaN(v) ? null : v;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function appliquerLargeur() {
|
||||
if (!root) return;
|
||||
var v = largeurStockee();
|
||||
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||
}
|
||||
function brancherPoignee(poignee) {
|
||||
poignee.addEventListener("pointerdown", function (ev) {
|
||||
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||
ev.preventDefault();
|
||||
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||
function bouger(e2) {
|
||||
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||
root.style.width = w + "px";
|
||||
}
|
||||
function lacher(e2) {
|
||||
document.removeEventListener("pointermove", bouger);
|
||||
document.removeEventListener("pointerup", lacher);
|
||||
var w = Math.round(root.getBoundingClientRect().width);
|
||||
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||
}
|
||||
document.addEventListener("pointermove", bouger);
|
||||
document.addEventListener("pointerup", lacher);
|
||||
});
|
||||
poignee.addEventListener("dblclick", function () {
|
||||
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||
appliquerLargeur();
|
||||
});
|
||||
}
|
||||
|
||||
function construire() {
|
||||
root = document.createElement("aside");
|
||||
root.className = "draco-panneau";
|
||||
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||
root.innerHTML =
|
||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||
'<div class="draco-panneau-hd">' +
|
||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||
' <div><div class="titre">Spark — agent commun</div>' +
|
||||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||
'</div>' +
|
||||
'<div class="draco-disc-bar">' +
|
||||
' <span class="d-titre">Discussion principale</span>' +
|
||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-discussions" hidden></div>' +
|
||||
'<div class="draco-panneau-outils">' +
|
||||
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||
'</div>' +
|
||||
'<div class="draco-panneau-msgs"></div>' +
|
||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||
' <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>';
|
||||
document.body.appendChild(root);
|
||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||
etatEl = root.querySelector(".draco-panneau-etat");
|
||||
inputEl = root.querySelector("input");
|
||||
sendBtn = root.querySelector("button[type=submit]");
|
||||
titreEl = root.querySelector(".d-titre");
|
||||
tiroirEl = root.querySelector(".draco-discussions");
|
||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||
root.querySelector("form").addEventListener("submit", envoyer);
|
||||
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||
appliquerTheme();
|
||||
surveillerTheme();
|
||||
appliquerLargeur();
|
||||
window.addEventListener("resize", appliquerLargeur);
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||
* console (autre origine), l'événement ne remonte pas
|
||||
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||
if (surOuverte) { modeSidebarConsole(); return; }
|
||||
if (root.classList.contains("open")) fermer();
|
||||
});
|
||||
}
|
||||
|
||||
function ajouterMsg(kind, texte) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg " + kind;
|
||||
d.textContent = texte;
|
||||
msgsEl.appendChild(d);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
|
||||
/* Message agent rendu en Markdown : le HTML vient du service
|
||||
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||
function ajouterMsgMd(texte, html) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "draco-msg agent md";
|
||||
if (html) d.innerHTML = html;
|
||||
else d.textContent = texte;
|
||||
msgsEl.appendChild(d);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
return d;
|
||||
}
|
||||
|
||||
function majEtat(cls, texte) {
|
||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||
etatEl.querySelector(".txt").textContent = texte;
|
||||
}
|
||||
function majTitre() {
|
||||
titreEl.textContent = convTitre || "Discussion principale";
|
||||
titreEl.title = titreEl.textContent;
|
||||
}
|
||||
|
||||
function verifierIdentite() {
|
||||
if (etatConnu) return;
|
||||
fetch(API + "/api/v1/me", { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 401) {
|
||||
majEtat("err", "Session partagée non reconnue");
|
||||
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
|
||||
etatConnu = true;
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function (me) {
|
||||
if (!me) return;
|
||||
etatConnu = true;
|
||||
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
|
||||
})
|
||||
.catch(function () {
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||
function chargerListeDiscussions() {
|
||||
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||
{ credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||
}
|
||||
|
||||
function demarrerDiscussions() {
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
var precedente = convStockee();
|
||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||
var active = trouvee || d.principale;
|
||||
if (!active) return;
|
||||
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||
stockerConv(); majTitre();
|
||||
chargerHistorique();
|
||||
})
|
||||
.catch(function () {
|
||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||
});
|
||||
}
|
||||
|
||||
function basculerDiscussion(c) {
|
||||
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||
stockerConv(); majTitre();
|
||||
tiroirEl.hidden = true;
|
||||
msgsEl.innerHTML = "";
|
||||
ajouterMsg("sys", (c.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||
chargerHistorique();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function nouvelleDiscussion() {
|
||||
fetch(API + "/api/v1/conversations", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain: domaine() })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (c) {
|
||||
basculerDiscussion(c);
|
||||
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
function actionDiscussion(id, action) {
|
||||
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||
return fetch(url, { method: methode, credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||
});
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function ligneDiscussion(c, actions) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "d-meta";
|
||||
var quand = "";
|
||||
try {
|
||||
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||
{ day: "numeric", month: "short" });
|
||||
} catch (e) { /* date illisible */ }
|
||||
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||
ligne.appendChild(meta);
|
||||
var sous = document.createElement("div");
|
||||
sous.className = "d-sous";
|
||||
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||
ligne.appendChild(sous);
|
||||
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||
if (actions && actions.length) {
|
||||
var btns = document.createElement("div");
|
||||
btns.className = "d-actions";
|
||||
actions.forEach(function (a) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button"; b.textContent = a.label;
|
||||
if (a.danger) b.className = "danger";
|
||||
b.addEventListener("click", function (ev) {
|
||||
ev.stopPropagation();
|
||||
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||
actionDiscussion(c.id, a.action)
|
||||
.then(function () {
|
||||
if (c.id === convId && a.action !== "desarchiver") {
|
||||
chargerListeDiscussions().then(function (d) {
|
||||
if (d.principale) {
|
||||
basculerDiscussion(d.principale);
|
||||
ajouterMsg("sys", a.action === "supprimer"
|
||||
? "Discussion supprimée — retour à la discussion principale."
|
||||
: "Discussion archivée — retour à la discussion principale.");
|
||||
}
|
||||
});
|
||||
}
|
||||
remplirTiroir();
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||
});
|
||||
btns.appendChild(b);
|
||||
});
|
||||
ligne.appendChild(btns);
|
||||
}
|
||||
return ligne;
|
||||
}
|
||||
|
||||
function remplirTiroir() {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||
chargerListeDiscussions()
|
||||
.then(function (d) {
|
||||
tiroirEl.innerHTML = "";
|
||||
var h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussion principale";
|
||||
tiroirEl.appendChild(h);
|
||||
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Discussions parallèles";
|
||||
tiroirEl.appendChild(h);
|
||||
if (!d.paralleles.length) {
|
||||
var vide = document.createElement("div");
|
||||
vide.className = "d-vide";
|
||||
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||
tiroirEl.appendChild(vide);
|
||||
}
|
||||
d.paralleles.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Archiver", action: "archive" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
if (d.archivees && d.archivees.length) {
|
||||
h = document.createElement("div");
|
||||
h.className = "d-section";
|
||||
h.textContent = "Archivées";
|
||||
tiroirEl.appendChild(h);
|
||||
d.archivees.forEach(function (c) {
|
||||
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||
{ label: "Désarchiver", action: "desarchiver" },
|
||||
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||
]));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function basculerTiroir() {
|
||||
tiroirEl.hidden = !tiroirEl.hidden;
|
||||
if (!tiroirEl.hidden) remplirTiroir();
|
||||
}
|
||||
|
||||
function chargerHistorique() {
|
||||
if (!convId) return;
|
||||
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||
.then(function (r) {
|
||||
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||
if (!r.ok) return null;
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convKind = d.kind; convTitre = d.title || convTitre;
|
||||
majTitre();
|
||||
if (!msgsEl.querySelector(".draco-msg")) {
|
||||
ajouterMsg("sys", (d.kind === "principale"
|
||||
? "Discussion principale — le fil sans fin."
|
||||
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||
}
|
||||
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||
(d.messages || []).forEach(function (m) {
|
||||
var el = m.role === "user"
|
||||
? ajouterMsg("user", m.content)
|
||||
: ajouterMsgMd(m.content, m.html);
|
||||
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||
}
|
||||
|
||||
function chargerAujourdhui() {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-auj";
|
||||
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
|
||||
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
|
||||
var t = d.sources.tiktak;
|
||||
h += "<h5>Tâches du jour — TikTak</h5>";
|
||||
if (t.status === "ok") {
|
||||
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
|
||||
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||
} else if (t.status === "non_configure") {
|
||||
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
|
||||
} else if (t.status === "non_autorise") {
|
||||
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
|
||||
} else {
|
||||
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
|
||||
}
|
||||
var k = d.sources.kanbante;
|
||||
h += "<h5>Cartes en Révision — Kanbante</h5>";
|
||||
if (k.status === "ok") {
|
||||
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
|
||||
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||
} else if (k.status === "non_autorise") {
|
||||
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
|
||||
} else {
|
||||
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
|
||||
}
|
||||
bloc.innerHTML = h;
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () {
|
||||
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
|
||||
});
|
||||
}
|
||||
|
||||
function chargerApprobations() {
|
||||
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
|
||||
d.approvals.forEach(function (a) {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-appro";
|
||||
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
|
||||
"<div class='resume'>" + esc(a.summary) + "</div>" +
|
||||
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
|
||||
"<button type='button' class='non'>Rejeter</button></div>";
|
||||
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
|
||||
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
|
||||
msgsEl.appendChild(bloc);
|
||||
});
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
|
||||
}
|
||||
|
||||
function decider(id, decision, bloc) {
|
||||
fetch(API + "/api/v1/approvals/" + id + "/decide", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ decision: decision })
|
||||
})
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
bloc.style.opacity = ".55";
|
||||
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||
})
|
||||
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
||||
}
|
||||
|
||||
/* ── Missions longues (J5) ───────────────────────────────────
|
||||
* Sélecteur d'exécuteur alimenté par le registre du service :
|
||||
* les externes non configurés sont montrés mais désactivés —
|
||||
* jamais un lancement qui simule. La liste se rafraîchit à
|
||||
* l'ouverture et après chaque lancement/annulation. */
|
||||
var STATUTS_MISSION = {
|
||||
queued: "En file", running: "En cours…", paused_question: "Question en attente",
|
||||
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
|
||||
};
|
||||
|
||||
function ouvrirMissions() {
|
||||
var bloc = document.createElement("div");
|
||||
bloc.className = "draco-missions";
|
||||
bloc.innerHTML =
|
||||
"<h5>Missions longues</h5>" +
|
||||
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
|
||||
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
|
||||
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
|
||||
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
|
||||
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
|
||||
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
|
||||
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
|
||||
msgsEl.appendChild(bloc);
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
fetch(API + "/api/v1/executors", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
var sel = bloc.querySelector(".m-exec");
|
||||
sel.innerHTML = "";
|
||||
d.executors.forEach(function (e) {
|
||||
if (e.family !== "sous-agent" && e.family !== "externe") return;
|
||||
var opt = document.createElement("option");
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
|
||||
(e.status === "non_configure" ? " — non configuré" : "");
|
||||
if (e.status === "non_configure") opt.disabled = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
|
||||
})
|
||||
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
|
||||
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
|
||||
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
|
||||
chargerMissions(bloc);
|
||||
}
|
||||
|
||||
function chargerMissions(bloc) {
|
||||
var zone = bloc.querySelector(".m-liste");
|
||||
fetch(API + "/api/v1/missions", { credentials: "include" })
|
||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
|
||||
zone.innerHTML = "";
|
||||
d.missions.slice(0, 10).forEach(function (m) {
|
||||
var ligne = document.createElement("div");
|
||||
ligne.className = "m-item";
|
||||
var entete = document.createElement("div");
|
||||
entete.className = "m-entete";
|
||||
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||
ligne.appendChild(entete);
|
||||
var meta = document.createElement("div");
|
||||
meta.className = "m-meta";
|
||||
meta.textContent = m.executor +
|
||||
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||
ligne.appendChild(meta);
|
||||
if (m.result) {
|
||||
var rapport = document.createElement("div");
|
||||
rapport.className = "m-rapport md";
|
||||
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||
ligne.appendChild(rapport);
|
||||
}
|
||||
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||
btn.addEventListener("click", function () {
|
||||
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" }, body: "{}"
|
||||
}).then(function () { chargerMissions(bloc); })
|
||||
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
|
||||
});
|
||||
ligne.appendChild(btn);
|
||||
}
|
||||
zone.appendChild(ligne);
|
||||
});
|
||||
})
|
||||
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
|
||||
}
|
||||
|
||||
function lancerMission(bloc) {
|
||||
var executor = bloc.querySelector(".m-exec").value;
|
||||
var titre = bloc.querySelector(".m-titre").value.trim();
|
||||
var prompt = bloc.querySelector(".m-prompt").value.trim();
|
||||
var carte = bloc.querySelector(".m-carte").value;
|
||||
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
|
||||
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
|
||||
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
|
||||
if (carte) corps.kanbante_number = parseInt(carte, 10);
|
||||
fetch(API + "/api/v1/missions", {
|
||||
method: "POST", credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(corps)
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
|
||||
chargerMissions(bloc);
|
||||
})
|
||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||
}
|
||||
|
||||
function envoyer(ev) {
|
||||
ev.preventDefault();
|
||||
var texte = inputEl.value.trim();
|
||||
if (!texte) return;
|
||||
inputEl.value = "";
|
||||
ajouterMsg("user", texte);
|
||||
sendBtn.disabled = true;
|
||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||
fetch(API + "/api/v1/chat", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.status === 401) {
|
||||
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
|
||||
majEtat("err", "Session partagée non reconnue");
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) {
|
||||
return r.json().catch(function () { return null; }).then(function (e) {
|
||||
attente.textContent = (e && e.detail) ? e.detail :
|
||||
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
convId = d.conversation_id;
|
||||
convKind = d.kind || convKind;
|
||||
if (d.title) convTitre = d.title;
|
||||
stockerConv(); majTitre();
|
||||
if (d.html) {
|
||||
attente.className = "draco-msg agent md";
|
||||
attente.innerHTML = d.html;
|
||||
} else {
|
||||
attente.textContent = d.reply;
|
||||
}
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
if (d.tools_used && d.tools_used.length) {
|
||||
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||
}
|
||||
majEtat("ok", "Connecté au service — domaine " + d.domain);
|
||||
})
|
||||
.catch(function () {
|
||||
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||
majEtat("err", "Service d'agents injoignable");
|
||||
})
|
||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||
}
|
||||
|
||||
function ouvrir() {
|
||||
if (!root) { construire(); demarrerDiscussions(); }
|
||||
root.classList.add("open");
|
||||
appliquerTheme();
|
||||
verifierIdentite();
|
||||
setTimeout(function () { inputEl.focus(); }, 300);
|
||||
}
|
||||
function fermer() { if (root) root.classList.remove("open"); }
|
||||
|
||||
window.DRACO_PANNEAU = {
|
||||
open: ouvrir,
|
||||
close: fermer,
|
||||
toggle: function () {
|
||||
if (root && root.classList.contains("open")) fermer(); else ouvrir();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -187,6 +187,52 @@ class FakeGitea:
|
||||
"404", request=request, response=_httpx.Response(404, request=request)
|
||||
)
|
||||
|
||||
supports_attachments = True
|
||||
|
||||
async def update_issue(self, owner, repo, number, title=None, body=None):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
if title is not None:
|
||||
issue["title"] = title
|
||||
if body is not None:
|
||||
issue["body"] = body
|
||||
return issue
|
||||
|
||||
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
|
||||
@property
|
||||
def _assets(self):
|
||||
return self.__dict__.setdefault("assets_store", {})
|
||||
|
||||
async def list_issue_assets(self, owner, repo, number):
|
||||
return list(self._assets.get((f"{owner}/{repo}", number), []))
|
||||
|
||||
async def upload_issue_asset(
|
||||
self, owner, repo, number, filename, content, content_type="application/octet-stream"
|
||||
):
|
||||
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
|
||||
asset = {
|
||||
"id": 700 + len(store) + number,
|
||||
"name": filename,
|
||||
"size": len(content),
|
||||
"download_count": 0,
|
||||
"created_at": "2026-10-23T10:00:00Z",
|
||||
"_content": content,
|
||||
"_type": content_type,
|
||||
}
|
||||
store.append(asset)
|
||||
return {k: v for k, v in asset.items() if not k.startswith("_")}
|
||||
|
||||
async def delete_issue_asset(self, owner, repo, number, asset_id):
|
||||
store = self._assets.get((f"{owner}/{repo}", number), [])
|
||||
self._assets[(f"{owner}/{repo}", number)] = [
|
||||
a for a in store if a["id"] != asset_id
|
||||
]
|
||||
|
||||
async def download_issue_asset(self, owner, repo, number, asset_id):
|
||||
for asset in self._assets.get((f"{owner}/{repo}", number), []):
|
||||
if asset["id"] == asset_id:
|
||||
return asset["_content"], asset["_type"]
|
||||
raise AssertionError("asset introuvable dans le faux")
|
||||
|
||||
async def get_issue_comments(self, owner, repo, number):
|
||||
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Lot 2, vague 2 — émission d'événements de Kanbante vers le
|
||||
journal du service d'agents (contrat ACC-0.2).
|
||||
|
||||
Couvre : l'enveloppe signée (HMAC recalculé côté test), l'inactivité
|
||||
sans clé, l'émission sur déplacement de carte (``kanbante.card.moved``)
|
||||
et sur entrée en Révision (``kanbante.card.review_requested``), et
|
||||
l'absence totale d'effet d'une panne du récepteur sur le métier.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from app import agent_journal, models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
CLE = "cle-kanbante-de-test"
|
||||
|
||||
|
||||
class Recepteur:
|
||||
"""Faux service d'agents : capture corps bruts et signatures."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.recus: list[tuple[bytes, str]] = []
|
||||
self._verrou = threading.Lock()
|
||||
recus = self.recus
|
||||
verrou = self._verrou
|
||||
|
||||
class Mains(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
corps = self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
with verrou:
|
||||
recus.append((corps, self.headers.get("X-Draco-Signature", "")))
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
|
||||
self._serveur = ThreadingHTTPServer(("127.0.0.1", 0), Mains)
|
||||
threading.Thread(target=self._serveur.serve_forever, daemon=True).start()
|
||||
self.url = f"http://127.0.0.1:{self._serveur.server_address[1]}/api/v1/evenements"
|
||||
|
||||
def enveloppes(self, type_: str | None = None, ref: str | None = None) -> list[dict]:
|
||||
with self._verrou:
|
||||
bruts = list(self.recus)
|
||||
envs = [json.loads(corps) for corps, _ in bruts]
|
||||
if type_ is not None:
|
||||
envs = [e for e in envs if e["type"] == type_]
|
||||
if ref is not None:
|
||||
envs = [e for e in envs if e["object"]["ref"] == ref]
|
||||
return envs
|
||||
|
||||
def attendre(self, type_: str, ref: str, timeout: float = 5.0) -> dict:
|
||||
"""Poll jusqu'à l'arrivée de l'événement voulu (envoi en
|
||||
arrière-plan : l'attente fait partie du contrat de test)."""
|
||||
limite = time.monotonic() + timeout
|
||||
while time.monotonic() < limite:
|
||||
trouves = self.enveloppes(type_, ref)
|
||||
if trouves:
|
||||
return trouves[0]
|
||||
time.sleep(0.05)
|
||||
raise AssertionError(f"Événement {type_} pour {ref} jamais reçu")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recepteur():
|
||||
return Recepteur()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def journal_actif(monkeypatch, recepteur):
|
||||
"""Émission configurée vers le faux récepteur local."""
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
|
||||
return recepteur
|
||||
|
||||
|
||||
def test_enveloppe_signee_verifiable(journal_actif):
|
||||
fil = agent_journal.emettre(
|
||||
"kanbante.card.moved",
|
||||
"issue:Projets/demo#1",
|
||||
"Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
actor="user",
|
||||
)
|
||||
assert fil is not None
|
||||
fil.join(timeout=5)
|
||||
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
corps, signature = journal_actif.recus[0]
|
||||
# La signature se revérifie indépendamment, comme le fait le service.
|
||||
assert signature == hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||
assert json.loads(corps) == env
|
||||
assert env["id"]
|
||||
assert env["source_app"] == "kanbante"
|
||||
assert env["domain"] == "pro"
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"] == {
|
||||
"ref": "issue:Projets/demo#1",
|
||||
"label": "Tâche un",
|
||||
"url": "http://gitea.test/Projets/demo/issues/1",
|
||||
}
|
||||
# occurred_at : ISO 8601 lisible par le service.
|
||||
datetime.fromisoformat(env["occurred_at"])
|
||||
|
||||
|
||||
def test_inactif_sans_cle(monkeypatch, recepteur):
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_CLE", raising=False)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
|
||||
assert agent_journal.actif() is False
|
||||
assert agent_journal.emettre(
|
||||
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
) is None
|
||||
time.sleep(0.3)
|
||||
assert recepteur.recus == []
|
||||
|
||||
|
||||
def _deplacer(client, numero: int, de: str, vers: str):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"issue_number": numero,
|
||||
"to_column": vers,
|
||||
"from_column": de,
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ok"] is True
|
||||
return r
|
||||
|
||||
|
||||
def test_deplacement_emet_moved(client, journal_actif):
|
||||
_deplacer(client, 1, "backlog", "in_progress")
|
||||
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"]["label"] == "Tâche un"
|
||||
assert env["object"]["url"] == "http://gitea.test/Projets/demo/issues/1"
|
||||
# Un déplacement hors Révision n'émet pas de review_requested.
|
||||
time.sleep(0.3)
|
||||
assert journal_actif.enveloppes(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
) == []
|
||||
|
||||
|
||||
def test_entree_en_revision_emet_review_requested(client, journal_actif):
|
||||
_deplacer(client, 1, "backlog", "review")
|
||||
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
env = journal_actif.attendre(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
)
|
||||
assert env["object"]["label"] == "Tâche un"
|
||||
assert env["actor"] == "user"
|
||||
# Sortir de Révision ne redemande pas de révision.
|
||||
_deplacer(client, 1, "review", "done")
|
||||
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
|
||||
time.sleep(0.3)
|
||||
assert len(journal_actif.enveloppes(
|
||||
"kanbante.card.review_requested", "issue:Projets/demo#1"
|
||||
)) == 1
|
||||
|
||||
|
||||
def test_panne_du_recepteur_sans_effet_sur_le_deplacement(client, monkeypatch):
|
||||
# Récepteur mort (port 1 fermé) mais émission configurée.
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:1/api/v1/evenements")
|
||||
_deplacer(client, 1, "backlog", "in_progress")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["in_progress"]] == [1]
|
||||
# L'échec d'envoi, lui, reste silencieux et borné dans le temps.
|
||||
fil = agent_journal.emettre(
|
||||
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
|
||||
"http://gitea.test/Projets/demo/issues/1",
|
||||
)
|
||||
assert fil is not None
|
||||
fil.join(timeout=10)
|
||||
assert not fil.is_alive()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Jetons d'API personnels (0.25.0) : modèle + accès MCP par Bearer.
|
||||
|
||||
Le service d'agents se fait refuser par Kanbante depuis la 0.24.0
|
||||
(auth partout, cookie/Basic seulement). Les jetons kbt_ lui donnent
|
||||
une identité machine propre, révocable, limitée à /mcp et /api/.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, models
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def utilisateur():
|
||||
# La base de test est partagée par toute la session : nom unique,
|
||||
# et nettoyage complet au teardown — le pont SSO (test_draco_sso)
|
||||
# a une sémantique « un seul compte en base » dont hérite la suite.
|
||||
import uuid
|
||||
uid = models.create_user(
|
||||
f"machine-{uuid.uuid4().hex[:8]}",
|
||||
accounts.hash_password("motdepasse"), role="member")
|
||||
user = models.get_user(uid)
|
||||
yield user
|
||||
with models.get_db() as conn:
|
||||
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
|
||||
|
||||
def test_jeton_cycle_de_vie(utilisateur):
|
||||
row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
assert clair.startswith("kbt_")
|
||||
# La base ne connaît que l'empreinte, jamais le texte clair.
|
||||
assert row["token_hash"] != clair and len(row["token_hash"]) == 64
|
||||
trouve = models.user_by_api_token(clair)
|
||||
assert trouve is not None and trouve["username"] == utilisateur["username"]
|
||||
assert models.list_api_tokens(utilisateur["id"])[0]["last_used_at"] is not None
|
||||
# Révocation : effet immédiat.
|
||||
assert models.revoke_api_token(utilisateur["id"], row["id"]) is True
|
||||
assert models.user_by_api_token(clair) is None
|
||||
# On ne révoque pas le jeton d'autrui.
|
||||
row2, _clair2 = models.create_api_token(utilisateur["id"], "autre")
|
||||
assert models.revoke_api_token(utilisateur["id"] + 999, row2["id"]) is False
|
||||
|
||||
|
||||
def test_jeton_invalide_ou_compte_desactive(utilisateur):
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "x")
|
||||
assert models.user_by_api_token("kbt_nimportequoi") is None
|
||||
assert models.user_by_api_token("") is None
|
||||
assert models.user_by_api_token("tck_autreformat") is None
|
||||
models.update_user(utilisateur["id"], active=0)
|
||||
assert models.user_by_api_token(clair) is None
|
||||
|
||||
|
||||
def test_mcp_par_jeton_quand_auth_active(monkeypatch, utilisateur):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
corps = {"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05"}}
|
||||
with TestClient(main.app) as client:
|
||||
# Sans jeton : 401 (le refus vu en production le 2026-10-21).
|
||||
assert client.post("/mcp", json=corps).status_code == 401
|
||||
# Avec le jeton Bearer : le handshake passe.
|
||||
r = client.post("/mcp", json=corps,
|
||||
headers={"Authorization": f"Bearer {clair}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["result"]["serverInfo"]["name"] == "kanbante"
|
||||
# Mais un jeton n'ouvre pas l'interface web.
|
||||
assert client.get("/", headers={"Authorization": f"Bearer {clair}",
|
||||
"Accept": "application/json"}).status_code == 401
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
|
||||
|
||||
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
|
||||
gardés par la même authentification que les lectures /mcp :
|
||||
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import accounts, models
|
||||
|
||||
|
||||
def _call(client, name, arguments, headers=None):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": name, "arguments": arguments},
|
||||
}
|
||||
return client.post("/mcp", json=payload, headers=headers or {})
|
||||
|
||||
|
||||
def _result_text(response):
|
||||
result = response.json()["result"]
|
||||
return result, result["content"][0]["text"]
|
||||
|
||||
|
||||
# ── Création de carte ──────────────────────────────────────
|
||||
|
||||
def test_mcp_create_issue_places_card(client, fake_gitea):
|
||||
r = _call(client, "create_issue", {
|
||||
"owner": "Projets", "repo": "demo",
|
||||
"title": "Carte créée par MCP", "body": "Corps de la carte",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
created = json.loads(text)
|
||||
assert created["title"] == "Carte créée par MCP"
|
||||
assert created["repo_full_name"] == "Projets/demo"
|
||||
assert created["column"] == "backlog"
|
||||
assert created["number"] > 0
|
||||
# L'écriture est passée par le client de forge existant.
|
||||
assert fake_gitea.created == [("Projets", "demo", "Carte créée par MCP")]
|
||||
# La carte est placée sur le board (lecture MCP relue).
|
||||
r = _call(client, "get_board", {"owner": "Projets", "repo": "demo"})
|
||||
board = json.loads(_result_text(r)[1])
|
||||
assert any(
|
||||
c["title"] == "Carte créée par MCP" for c in board["backlog"]
|
||||
)
|
||||
# Journal : création tracée avec la source « mcp ».
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
created_events = [
|
||||
e for e in events
|
||||
if e["event_type"] == "created"
|
||||
and e["issue_number"] == created["number"]
|
||||
]
|
||||
assert created_events and created_events[0]["source"] == "mcp"
|
||||
|
||||
|
||||
def test_mcp_create_issue_column_choice(client, fake_gitea):
|
||||
r = _call(client, "create_issue", {
|
||||
"owner": "Projets", "repo": "demo",
|
||||
"title": "Directement à faire", "column": "todo",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
assert json.loads(text)["column"] == "todo"
|
||||
|
||||
|
||||
def test_mcp_create_issue_validation_errors(client, fake_gitea):
|
||||
# Titre manquant : « Missing argument » (patron d'erreur du serveur).
|
||||
r = _call(client, "create_issue", {"owner": "Projets", "repo": "demo"})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Missing argument" in text
|
||||
# Titre vide et colonne inconnue : erreurs métier en isError.
|
||||
for arguments in (
|
||||
{"owner": "Projets", "repo": "demo", "title": " "},
|
||||
{"owner": "Projets", "repo": "demo", "title": "X",
|
||||
"column": "nimporte"},
|
||||
):
|
||||
result, _text = _result_text(_call(client, "create_issue", arguments))
|
||||
assert result["isError"] is True
|
||||
assert fake_gitea.created == []
|
||||
|
||||
|
||||
# ── Commentaire ────────────────────────────────────────────
|
||||
|
||||
def test_mcp_add_comment_posts_on_forge(client, fake_gitea):
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"body": "Commentaire du service d'agents",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is False
|
||||
payload = json.loads(text)
|
||||
assert payload["number"] == 1
|
||||
assert payload["repo_full_name"] == "Projets/demo"
|
||||
assert payload["body"] == "Commentaire du service d'agents"
|
||||
assert fake_gitea.posted_comments == [
|
||||
("Projets", "demo", 1, "Commentaire du service d'agents")
|
||||
]
|
||||
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||
commented = [e for e in events if e["event_type"] == "commented"]
|
||||
assert commented and commented[0]["source"] == "mcp"
|
||||
|
||||
|
||||
def test_mcp_add_comment_validation_errors(client, fake_gitea):
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1, "body": " ",
|
||||
})
|
||||
assert _result_text(r)[0]["isError"] is True
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "body": "sans numéro",
|
||||
})
|
||||
result, text = _result_text(r)
|
||||
assert result["isError"] is True
|
||||
assert "Missing argument" in text
|
||||
assert fake_gitea.posted_comments == []
|
||||
|
||||
|
||||
def test_mcp_write_tools_are_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["create_issue"]["inputSchema"]["required"] == [
|
||||
"owner", "repo", "title",
|
||||
]
|
||||
assert tools["add_comment"]["inputSchema"]["required"] == [
|
||||
"owner", "repo", "number", "body",
|
||||
]
|
||||
# Aucun outil destructeur n'apparaît.
|
||||
assert not any(
|
||||
name.startswith(("delete", "remove", "close"))
|
||||
for name in tools
|
||||
)
|
||||
|
||||
|
||||
# ── Garde par jeton kbt_ (auth active) ─────────────────────
|
||||
|
||||
@pytest.fixture()
|
||||
def utilisateur():
|
||||
# Base de test partagée : nom unique, nettoyage complet au
|
||||
# teardown (le pont SSO a une sémantique « un seul compte »).
|
||||
uid = models.create_user(
|
||||
f"mcp-{uuid.uuid4().hex[:8]}",
|
||||
accounts.hash_password("motdepasse"), role="member")
|
||||
user = models.get_user(uid)
|
||||
yield user
|
||||
with models.get_db() as conn:
|
||||
conn.execute("DELETE FROM api_tokens WHERE user_id = ?", (uid,))
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (uid,))
|
||||
|
||||
|
||||
def test_mcp_writes_guarded_by_api_token(monkeypatch, fake_gitea, utilisateur):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
_row, clair = models.create_api_token(utilisateur["id"], "service-agents")
|
||||
jeton = {"Authorization": f"Bearer {clair}"}
|
||||
create_args = {
|
||||
"owner": "Projets", "repo": "demo", "title": "Carte par jeton",
|
||||
}
|
||||
with TestClient(main.app) as client:
|
||||
# Sans jeton : refusé (401), aucune écriture sur la forge.
|
||||
assert _call(client, "create_issue", create_args).status_code == 401
|
||||
# Jeton inconnu : refusé aussi.
|
||||
faux = {"Authorization": "Bearer kbt_inconnu"}
|
||||
assert _call(
|
||||
client, "create_issue", create_args, headers=faux
|
||||
).status_code == 401
|
||||
assert fake_gitea.created == []
|
||||
# Jeton valide : création et commentaire passent.
|
||||
r = _call(client, "create_issue", create_args, headers=jeton)
|
||||
assert r.status_code == 200
|
||||
assert _result_text(r)[0]["isError"] is False
|
||||
assert fake_gitea.created == [("Projets", "demo", "Carte par jeton")]
|
||||
r = _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1,
|
||||
"body": "Vu par le service d'agents",
|
||||
}, headers=jeton)
|
||||
assert _result_text(r)[0]["isError"] is False
|
||||
# Le même jeton n'ouvre jamais l'interface web.
|
||||
assert client.get(
|
||||
"/", headers={**jeton, "Accept": "application/json"}
|
||||
).status_code == 401
|
||||
# Révocation : effet immédiat sur les écritures.
|
||||
assert models.revoke_api_token(utilisateur["id"], _row["id"]) is True
|
||||
assert _call(
|
||||
client, "create_issue", create_args, headers=jeton
|
||||
).status_code == 401
|
||||
assert _call(client, "add_comment", {
|
||||
"owner": "Projets", "repo": "demo", "number": 1, "body": "x",
|
||||
}, headers=jeton).status_code == 401
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Palier 20 (0.30.0): colonnes personnalisables par
|
||||
espace et mapping étiquette ↔ colonne configurable."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import board_columns as bcols
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as client:
|
||||
client.get("/login")
|
||||
token = client.cookies.get("kanbante_csrf") or ""
|
||||
client.post(
|
||||
"/login",
|
||||
data={
|
||||
"username": "bruno",
|
||||
"password": "motdepasse",
|
||||
"next": "/",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_cols():
|
||||
"""Un espace dédié rattaché à la connexion héritée."""
|
||||
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
|
||||
models.set_workspace_connections(ws_id, [1])
|
||||
bcols.invalidate(ws_id)
|
||||
yield ws_id
|
||||
models.clear_board_columns(ws_id)
|
||||
bcols.invalidate(ws_id)
|
||||
models.delete_workspace(ws_id)
|
||||
|
||||
|
||||
def _issue(number, title, labels=None):
|
||||
return {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": "",
|
||||
"state": "open",
|
||||
"labels": labels or [],
|
||||
"assignee": None,
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
"due_date": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"updated_at": "2026-09-02T09:00:00Z",
|
||||
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||
"pull_request": None,
|
||||
}
|
||||
|
||||
|
||||
# ── Défauts et service ────────────────────────────────────
|
||||
|
||||
def test_defaults_match_historical_columns(ws_cols):
|
||||
columns = bcols.get_columns(ws_cols)
|
||||
assert [c["key"] for c in columns] == [
|
||||
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||
]
|
||||
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
|
||||
assert bcols.label_for(ws_cols, "review") == "kanban:review"
|
||||
assert bcols.label_for(ws_cols, "backlog") is None
|
||||
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
|
||||
assert bcols.wall_keys(ws_cols) == [
|
||||
"todo", "in_progress", "qa", "review",
|
||||
]
|
||||
|
||||
|
||||
def test_custom_column_at_model_level(ws_cols):
|
||||
columns = bcols.get_columns(ws_cols)
|
||||
models.upsert_board_column(
|
||||
ws_cols,
|
||||
{
|
||||
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
|
||||
"position": 4, "label": "etape:dev", "aliases": "",
|
||||
"kind": "flow", "in_focus": 1,
|
||||
},
|
||||
)
|
||||
bcols.invalidate(ws_cols)
|
||||
assert "dev" in models.column_keys(ws_cols)
|
||||
models.move_issue(
|
||||
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert [c["issue_number"] for c in state["dev"]] == [5]
|
||||
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
|
||||
assert "dev" in bcols.focus_keys(ws_cols)
|
||||
# Un autre espace n'est pas affecté.
|
||||
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
|
||||
try:
|
||||
assert "dev" not in models.column_keys(other)
|
||||
finally:
|
||||
models.delete_workspace(other)
|
||||
|
||||
|
||||
# ── Panneau d'administration ──────────────────────────────
|
||||
|
||||
def test_admin_columns_crud(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
page = auth_client.get("/admin/columns")
|
||||
assert page.status_code == 200
|
||||
assert "Colonnes du board" in page.text
|
||||
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "🛠️ En développement",
|
||||
"color": "#224466",
|
||||
"label": "etape:dev",
|
||||
"aliases": "en-dev",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
keys = bcols.keys(ws_cols)
|
||||
assert "en_developpement" in keys
|
||||
# Insérée avant « done ».
|
||||
assert keys.index("en_developpement") == keys.index("done") - 1
|
||||
|
||||
# Renommage + mapping via le formulaire de la colonne.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/en_developpement",
|
||||
data={
|
||||
"name": "🚀 Dev",
|
||||
"color": "#224466",
|
||||
"label": "etape:dev",
|
||||
"aliases": "en-dev, dev",
|
||||
"in_focus": "1",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
|
||||
lookup = bcols.label_lookup(ws_cols)
|
||||
assert lookup["etape:dev"] == "en_developpement"
|
||||
assert lookup["en-dev"] == "en_developpement"
|
||||
|
||||
# Le board affiche la nouvelle colonne.
|
||||
board = auth_client.get("/board/Projets/demo")
|
||||
assert 'data-column="en_developpement"' in board.text
|
||||
assert "🚀 Dev" in board.text
|
||||
|
||||
|
||||
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "Attente client", "color": "#445566", "label": "",
|
||||
"aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "attente_client" in bcols.keys(ws_cols)
|
||||
models.move_issue(
|
||||
"Projets/demo", 5, "attente_client",
|
||||
workspace_id=ws_cols, connection_id=1,
|
||||
)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/admin/columns/attente_client/delete",
|
||||
data={"target": "backlog", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 303
|
||||
assert "attente_client" not in bcols.keys(ws_cols)
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert any(c["issue_number"] == 5 for c in state["backlog"])
|
||||
# Les colonnes système ne se suppriment pas.
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/backlog/delete",
|
||||
data={"target": "todo", "csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "backlog" in bcols.keys(ws_cols)
|
||||
|
||||
|
||||
def test_admin_columns_reset(auth_client, ws_cols):
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "Temporaire", "color": "#445566", "label": "",
|
||||
"aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "temporaire" in bcols.keys(ws_cols)
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/reset",
|
||||
data={"csrf_token": token},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert bcols.keys(ws_cols) == [
|
||||
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||
]
|
||||
|
||||
|
||||
# ── Mapping : adoption par alias et miroir personnalisé ──
|
||||
|
||||
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
|
||||
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
|
||||
colonne todo, fait adopter À faire par le board."""
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns/todo",
|
||||
data={
|
||||
"name": "📋 À faire", "color": "#3b82f6",
|
||||
"label": "kanban:todo", "aliases": "kanban_a_faire",
|
||||
"csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_issue(42, "Carte Flowdeck", labels=[
|
||||
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
|
||||
])
|
||||
)
|
||||
payload = auth_client.get("/api/board/Projets/demo").json()
|
||||
assert "42" in payload["columns"]["todo"]["html"]
|
||||
state = models.get_board_state(
|
||||
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||
)
|
||||
assert any(c["issue_number"] == 42 for c in state["todo"])
|
||||
|
||||
|
||||
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
|
||||
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
|
||||
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||
token = csrf_from(auth_client)
|
||||
auth_client.post(
|
||||
"/admin/columns",
|
||||
data={
|
||||
"name": "En développement", "color": "#224466",
|
||||
"label": "etape:dev", "aliases": "", "csrf_token": token,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
token = csrf_from(auth_client)
|
||||
r = auth_client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo",
|
||||
"issue_number": "5",
|
||||
"to_column": "en_developpement",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
|
||||
)
|
||||
names = [l["name"] for l in issue.get("labels") or []]
|
||||
assert "etape:dev" in names
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Palier 21 (0.31.0): description éditable, pièces jointes
|
||||
d'issues, clic carte robuste (délégation côté JS)."""
|
||||
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_issue_update_title_and_body(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/update",
|
||||
data={
|
||||
"title": "Tâche un — précisée",
|
||||
"body": "## Contexte\n\nLa description qui manquait.",
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
payload = r.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["issue"]["body"].startswith("## Contexte")
|
||||
issue = next(
|
||||
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1
|
||||
)
|
||||
assert issue["title"] == "Tâche un — précisée"
|
||||
assert "description qui manquait" in issue["body"]
|
||||
# Le journal consigne l'édition.
|
||||
events = client.get("/api/events?repo=Projets/demo").json()["events"]
|
||||
assert any(e["event_type"] == "edited" for e in events)
|
||||
|
||||
|
||||
def test_issue_update_rejects_empty_title(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/update",
|
||||
data={"title": " ", "body": "x", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_issue_detail_includes_assets_support(client, fake_gitea):
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert data["issue"]["attachments_supported"] is True
|
||||
assert data["issue"]["assets"] == []
|
||||
|
||||
|
||||
def test_asset_roundtrip(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/assets",
|
||||
data={"csrf_token": token},
|
||||
files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
asset = r.json()["asset"]
|
||||
assert asset["name"] == "notes.txt"
|
||||
assert asset["size"] == len(b"bonjour kanbante")
|
||||
|
||||
# Visible dans le détail.
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"]
|
||||
|
||||
# Téléchargement proxifié : contenu + disposition.
|
||||
dl = client.get(
|
||||
f"/api/issues/Projets/demo/1/assets/{asset['id']}/download"
|
||||
)
|
||||
assert dl.status_code == 200
|
||||
assert dl.content == b"bonjour kanbante"
|
||||
assert "notes.txt" in dl.headers["content-disposition"]
|
||||
|
||||
# Suppression.
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete",
|
||||
data={"csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = client.get("/api/issues/Projets/demo/1").json()
|
||||
assert data["issue"]["assets"] == []
|
||||
|
||||
|
||||
def test_asset_upload_rejects_empty_and_huge(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/assets",
|
||||
data={"csrf_token": token},
|
||||
files={"attachment": ("vide.txt", b"", "text/plain")},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
|
||||
page = client.get("/board/Projets/demo")
|
||||
assert page.status_code == 200
|
||||
assert 'id="detail-edit-btn"' in page.text
|
||||
assert 'id="detail-edit-form"' in page.text
|
||||
assert 'id="detail-assets"' in page.text
|
||||
assert 'id="asset-form"' in page.text
|
||||
# Création avec description dépliable (0.31.1 : <details>
|
||||
# natif plutôt qu'un bouton piloté en JS).
|
||||
assert 'id="new-issue-body"' in page.text
|
||||
assert 'id="new-issue-desc"' in page.text
|
||||
assert 'class="composer"' in page.text
|
||||
|
||||
|
||||
async def test_gitea_download_follows_browser_download_url():
|
||||
"""Gitea renvoie la fiche JSON de l'asset, pas le binaire :
|
||||
le téléchargement doit suivre browser_download_url (réel
|
||||
1.27.3, attrapé au smoke de la 0.31.0)."""
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
def handler(request):
|
||||
url = str(request.url)
|
||||
if url.endswith("/assets/42"):
|
||||
return httpx.Response(200, json={
|
||||
"id": 42, "name": "preuve.txt",
|
||||
"browser_download_url": "https://gitea.test/attachments/uuid-1",
|
||||
})
|
||||
if url.endswith("/attachments/uuid-1"):
|
||||
assert request.headers.get("authorization", "").startswith("token ")
|
||||
return httpx.Response(
|
||||
200, content=b"contenu reel",
|
||||
headers={"content-type": "text/plain"},
|
||||
)
|
||||
return httpx.Response(404)
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="https://gitea.test", token="jeton" + "-factice",
|
||||
cache_ttl=0, transport=httpx.MockTransport(handler),
|
||||
)
|
||||
content, content_type = await client.download_issue_asset(
|
||||
"Projets", "demo", 1, 42
|
||||
)
|
||||
assert content == b"contenu reel"
|
||||
assert content_type == "text/plain"
|
||||
@@ -121,6 +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",
|
||||
]
|
||||
|
||||
|
||||
@@ -153,7 +154,7 @@ def test_mcp_unknown_method_and_tool(client):
|
||||
assert r.json()["error"]["code"] == -32601
|
||||
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is True # read-only server: no such tool
|
||||
assert result["isError"] is True # aucun outil de ce nom (pas de suppression)
|
||||
|
||||
|
||||
def test_mcp_notification_gets_202(client):
|
||||
@@ -164,7 +165,10 @@ def test_mcp_notification_gets_202(client):
|
||||
def test_mcp_info_endpoint(client):
|
||||
r = client.get("/mcp")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["read_only"] is True
|
||||
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
|
||||
# plus en lecture seule, et il le déclare honnêtement.
|
||||
assert r.json()["read_only"] is False
|
||||
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
|
||||
|
||||
|
||||
# ── Board : gestion des étiquettes ────────────────────────
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""0.24.2 — #29 : base sombre pour tous les champs de saisie.
|
||||
|
||||
Sans règle de fond, les champs hors contexte stylé gardaient le
|
||||
fond blanc du navigateur (réinitialisation de mot de passe,
|
||||
textarea des espaces, valeurs du panneau des paramètres).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
R = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_base_champs_sombre():
|
||||
css = (R / "static/css/style.css").read_text(encoding="utf-8")
|
||||
assert "color-scheme: dark" in css
|
||||
assert ":where(input:not([type=\"checkbox\"])" in css
|
||||
assert "textarea, select" in css
|
||||
assert "background: var(--surface-2);" in css
|
||||
# la base est en spécificité zéro : les contextes restent maîtres
|
||||
assert ".palette-input-row input" in css
|
||||
assert ".login-form input" in css
|
||||
Reference in New Issue
Block a user