Compare commits

..
15 Commits
Author SHA1 Message Date
Spark 53f54b1b79 feat(0.37.1): version du service d'agents visible dans le panneau Spark (mode sidebar) — fusion #64
CI / test (push) Failing after 2s
2026-10-26 16:49:25 +00:00
Spark 80fc260a40 feat(0.37.0): tri dashboard « En attente de revue » + sens des tris numériques corrigé
CI / test (push) Successful in 2m27s
- Nouveau critère 🔍 En attente de revue ↓ (compteur de la
  colonne review exposé en data-review sur les cartes) ;
  s'applique à toutes les grilles, Épinglés en tête,
  préférence mémorisée comme les autres tris.
- Correctif attrapé au contrôle navigateur réel : le tri
  « Issues ouvertes ↓ » historique triait croissant — le
  comparateur inversait les clés numériques. Modes open et
  review en ka - kb : plus grand compteur en premier.
  Vérifié sur données réelles (Épinglés : 196, 95, 32, 5, 4).
- 299 tests verts (3 nouveaux, palier 25).
2026-10-26 11:19:53 -04:00
Spark 8ad3a86e60 feat(0.36.0): outil MCP move_issue — déplacement de carte par le service d'agents
CI / test (push) Failing after 1m27s
Le serveur MCP n'exposait en écriture que create_issue et
add_comment : un déplacement approuvé côté service d'agents
(kanbante.move_card, vague H3) aurait échoué faute d'outil.

- move_issue (owner, repo, number, column) réutilise le service
  de déplacement de l'interface (_do_move_card, extrait de
  POST /api/move sans changement de comportement) : board
  local, journal source « mcp », miroir d'étiquette kanban:*
  remplacée, autres étiquettes préservées.
- Colonne acceptée par clé, par étiquette de forge ou par
  libellé français affiché (définitions de l'espace).
- Garde doctrine : la cible done (« Terminé ») est refusée
  avant toute écriture — les agents s'arrêtent en Révision.
- Erreurs propres : colonne inconnue (liste des valides),
  dépôt/issue inconnu, pull request refusée.
- 296 tests verts (5 nouveaux + épingles de la liste d'outils
  mises à jour).
2026-10-26 15:06:02 +00:00
Spark a92353f8ef feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s
- Clients : create_milestone / update_milestone (cache
  invalidé) ; GitHub hérite des mêmes endpoints.
- API Kanbante : création, modification (titre/échéance,
  échéance vidée = retirée), fermeture ; CSRF, validation
  de date avant toute écriture, erreurs forge en 502.
- Page Projet : formulaire 'Nouveau jalon' + édition/
  fermeture par jalon (project.js versionné).
- Échéance à midi UTC : à minuit, le fuseau de l'instance
  Gitea faisait reculer la date affichée d'un jour (réel).
- 291 tests verts (5 nouveaux, palier 24) ; cycle réel
  complet sur Projets/kanbante (jalons smoke #37/#38 créés,
  dates vérifiées, fermés).
2026-10-26 10:53:29 -04:00
Spark 8c889878ee feat(0.34.0): vue « En vol partout » /inflight (issue #24)
CI / test (push) Successful in 2m24s
- L'encours (colonnes en vol de l'espace) tous projets
  actifs, regroupé PAR colonne, trié par âge décroissant
  dans la colonne (pipeline _card_ages du board).
- En-tête : total + compteurs par colonne + limite WIP
  globale (somme des projets qui en définissent).
- Entrées Pilotage (menu latéral) et palette Ctrl+K ; SSE
  /api/stream via live.js comme les autres vues.
- Réutilise _collect_focus_groups : aucun appel forge en
  plus. 286 tests verts (5 nouveaux, palier 23) ; vérifié
  en Chromium réel (356 cartes en vol sur les vraies
  données, dont la carte #24 elle-même).
2026-10-26 10:48:45 -04:00
Spark dfb4e595ff fix(test): horodatage capturé une fois (flake CI, écart d'1 s)
CI / test (push) Successful in 2m23s
test_column_entry_times_ignores_comments évaluait _days_ago(3)
à la création puis à l'assertion : une bascule de seconde
entre les deux faisait échouer le run CI 2333 alors que le
run jumeau (tag v0.33.0) et la suite locale passaient.
2026-10-26 10:23:06 -04:00
Spark 9fe35f6c8f feat(0.33.0): filtrage professionnel sur Mes issues (/my)
CI / test (push) Successful in 2m26s
- Barre de filtres en carte : recherche texte (titre, projet,
  #numéro, étiquettes ; accents/casse ignorés, raccourci /),
  listes Projet et Étiquette avec compteurs, chips de
  colonnes colorées multi-sélection, 4 tris.
- Facettes calculées côté serveur ; filtrage instantané côté
  client (attributs data-*), compteur en direct, compteurs
  de section, état vide + réinitialisation.
- Filtres structurels persistés en localStorage.
- 281 tests verts (3 nouveaux, palier 22) ; vérifié en
  Chromium réel sur les vraies données (467 issues :
  recherche 'agent' -> 24 résultats instantanés).
2026-10-26 10:20:37 -04:00
Spark d419fe3086 fix(0.32.2): résolution par requête des URL dracodev, base agent dérivée et CSP du panneau
CI / test (push) Successful in 2m21s
La dérivation 0.32.1 ne couvrait que les URL sœurs depuis
AGENT_EVENEMENTS_URL, jamais agent_url : sur la dev, le panneau
visait le défaut prod (agent injoignable, prod non installée),
console morte. Désormais, pour tout rendu à une requête (barre,
iframe du panneau, CSP frame-src) : explicite > hôte public au
motif fermé dracodev > dérivation élargie (événements ou base du
service) > défaut officiel. Contextes sans requête inchangés
sinon ; aucun SSO ajouté.
2026-10-26 02:07:46 +00:00
Spark 65c467bc90 fix(0.32.1): URL sœurs dracodev dérivées de l'environnement de l'agent
CI / test (push) Successful in 2m58s
2026-10-25 23:49:33 +00:00
Spark 4a97e0d0dc refactor: noms publics dracodev par configuration (dev/prod)
CI / test (push) Successful in 2m22s
2026-10-25 18:02:21 +00:00
Spark 7852d860fe fix(0.31.1): compositeur de création refait, description en <details> natif
CI / test (push) Successful in 2m13s
- Carte compositeur : en-tête + colonne d'arrivée, champ
  pleine largeur, bouton primaire ; neutralise le
  display:flex imposé par .board-toolbar aux formulaires.
- 'Ajouter une description' devient un <details> natif :
  aucun JS requis, insensible au cache et aux erreurs de
  scripts (le bouton 0.31.0 pouvait rester inerte).
- Assets du board versionnés (?v=APP_VERSION) contre les
  mélanges de cache après redéploiement ; Ctrl+Entrée.
- Vérifié en Chromium réel sur le vrai board (captures
  contrôlées) ; 261 tests verts.
2026-10-23 09:48:17 -04:00
Spark d4cf8d1275 feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s
- Tiroir : édition du titre et de la description (PATCH
  forge, journal 'edited', EVENT_TYPES étendu), création
  avec description dépliable.
- Pièces jointes Gitea de bout en bout : client
  list/upload/delete/download (le téléchargement suit
  browser_download_url — l'endpoint asset ne sert que du
  JSON, attrapé au smoke réel), routes proxifiées avec
  CSRF et plafond 25 Mio, UI liste/envoi/suppression ;
  GitHub : supports_attachments=False, message propre.
- Clic carte en délégation d'événement : les écouteurs par
  carte mouraient au premier rafraîchissement live.
- 261 tests verts (7 nouveaux, palier 21) ; cycle réel
  complet validé sur une issue jetable du vrai Gitea
  (création, édition, envoi, téléchargement, suppression).
2026-10-23 09:27:38 -04:00
Spark 28322c12cf feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
  ordre libres, étiquette mappée + alias, drapeau Focus) ;
  définitions semées paresseusement depuis les valeurs
  historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
  étiquette→colonne (alias compris, ex. kanban_a_faire de
  Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
  capture, santé, focus, wall, dashboard branchés sur les
  colonnes de l'espace ; validations dynamiques dans models
  (move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
  renommer, réordonner, mapper, supprimer avec migration des
  cartes vers une colonne choisie, réinitialiser ; bouton
  🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
  le vrai Gitea : création, affichage au board, suppression.
2026-10-23 08:38:02 -04:00
Spark 3964323abd Panneau Spark V3 : console en surimpression pleine page (0.29.0)
CI / test (push) Successful in 2m11s
2026-10-23 04:10:35 +00:00
Spark 4230ac1052 Panneau Spark : bouton « Console ↗ » vers la console du service (0.28.1)
CI / test (push) Successful in 2m6s
2026-10-22 21:18:49 +00:00
41 changed files with 4475 additions and 238 deletions
+27
View File
@@ -0,0 +1,27 @@
# Kanbante — exemple d'environnement (copier en .env, jamais versionné)
# Infrastructure (un seul Gitea pour dev et prod, hors renommage)
GITEA_URL=https://git.dracodev.net
GITEA_TOKEN=
# Authentification locale
KANBANTE_AUTH_USER=
KANBANTE_AUTH_PASSWORD=
KANBANTE_REQUIRE_AUTH=0
# Noms publics dracodev — défauts = noms OFFICIELS (prod).
# Environnement dev : déclarer ici les noms en -dev, sans toucher au code.
KANBANTE_PUBLIC_URL=https://kanbante.dracodev.net
DRACO_SSO_URL=https://og.dracodev.net
DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
DRACO_TIKTAK_URL=https://tiktak.dracodev.net
AGENT_URL=https://agent.dracodev.net
# Exemples dev (à décommenter sur l'hôte dev) :
# KANBANTE_PUBLIC_URL=https://kanbante-dev.dracodev.net
# DRACO_SSO_URL=https://og-dev.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck-dev.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# AGENT_URL=https://agent-dev.dracodev.net
# Journal d'événements du service d'agents (signé HMAC)
# AGENT_EVENEMENTS_URL= # vide = dérivé de AGENT_URL (/api/v1/evenements)
AGENT_EVENEMENTS_CLE=
# Pont SSO draco_session (émis par ObsiGate ; vide = inerte)
DRACO_SESSION_SECRET=
DRACO_SSO_MACHINE_KEY=
+428
View File
@@ -1,5 +1,433 @@
# Changelog
## 0.37.1 — 2026-10-26
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
une valeur codée en dur ; service injoignable ou panneau non configuré
= rien d'affiché, aucune erreur visible.
## 0.37.0 — 2026-10-26
### Ajouté — tri « En attente de revue » sur le dashboard
Demande de Bruno : pouvoir ordonner les projets — section
**Épinglés** en tête — par ce qui attend sa validation.
- Nouveau critère dans le sélecteur de tri du dashboard,
en première place après « Par défaut » : **🔍 En attente
de revue ↓** — les projets s'ordonnent par le nombre de
cartes dans la colonne de validation (clé `review` de
l'espace), décroissant.
- Le compteur était déjà calculé pour les chips de
progression des cartes : il est maintenant exposé en
`data-review` sur chaque carte projet et consommé par le
moteur de tri partagé — le critère s'applique à **toutes
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
avec ou sans regroupement par propriétaire, et la
préférence est mémorisée comme les autres tris.
- Les critères existants (activité Gitea, activité kanban,
issues ouvertes, nom) sont inchangés.
### Corrigé — sens des tris numériques du dashboard
Le contrôle en navigateur réel a révélé que le tri
« Issues ouvertes ↓ » triait en réalité **croissant**
depuis son introduction : le comparateur partagé inversait
les clés numériques. Les modes numériques (`open` et le
nouveau `review`) trient maintenant en soustraction de
clés — le plus grand compteur en premier, comme l'annonce
le « ↓ ». Vérifié sur les données réelles : Épinglés
ordonnés 196, 95, 32, 5, 4 en attente de revue.
## 0.36.0 — 2026-10-26
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
Le service d'agents sait proposer et appliquer des déplacements
de cartes par approbation (`kanbante.move_card`), mais le serveur
MCP de Kanbante n'exposait en écriture que `create_issue` et
`add_comment` : un déplacement approuvé aurait échoué faute
d'outil. Le trou est fermé.
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
`number`, `column`) : déplace une carte vers une autre colonne
en réutilisant le service de déplacement de l'interface
(`_do_move_card`, extrait de `POST /api/move` sans changement
de comportement) — board local, journal des événements
(source « mcp »), émission vers le journal du service
d'agents et miroir d'étiquette.
- **Étiquette kanban remplacée, les autres préservées** : le
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
pose celle de la colonne cible ; les étiquettes libres de
l'issue (ex. « bug ») ne sont jamais touchées.
- **Colonne souple** : la clé (`review`), l'étiquette de forge
(`kanban:review`, alias d'espace compris) et le libellé
français affiché (« Révision », « À faire », « En cours »…)
sont acceptés ; les libellés viennent de la définition des
colonnes de l'espace, pas d'une table figée.
- **Garde doctrine** : la cible `done` (« Terminé ») est
**refusée avant toute écriture**, avec une erreur explicite —
Terminé appartient à Bruno dans son interface, les agents
s'arrêtent en Révision.
- **Erreurs propres** : colonne inconnue (avec la liste des
colonnes valides), dépôt ou issue inconnu, pull request
refusée comme n'étant pas une carte.
- Même garde que les autres outils /mcp (session, Basic ou
jeton d'API `kbt_`).
## 0.35.0 — 2026-10-26
### Ajouté — jalons en écriture depuis la page Projet (#26)
Seconde et dernière carte du backlog de la feuille de
route « Gestion de projets » : les jalons n'étaient que lus
dans Kanbante, ils s'écrivent maintenant — la forge reste
la source de vérité, rien n'est stocké localement.
- **Créer** un jalon (titre, description et échéance
optionnelles) depuis la page Projet, formulaire
« ➕ Nouveau jalon ».
- **Modifier** titre et échéance d'un jalon ouvert
(formulaire déplié par jalon ; échéance vidée = échéance
retirée, par un `null` explicite côté forge).
- **Fermer** un jalon, avec confirmation (ses issues
restent ouvertes).
- Écritures dans la forge via son API (`POST/PATCH
.../milestones`), CSRF vérifié, échéance invalide
refusée proprement **avant** toute écriture ; le board
filtré et le bandeau de progression reflètent le nouveau
jalon au chargement suivant, sans redémarrage.
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
dans le fuseau de son instance — à minuit UTC, l'échéance
affichée reculait d'un jour (attrapé au smoke réel).
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
réassigner les issues entre jalons.
## 0.34.0 — 2026-10-26
### Ajouté — vue « En vol partout » /inflight (issue #24)
Dernière carte du backlog de la feuille de route « Gestion
de projets », première du classement big picture (pilotage
quotidien des agents avant gestion de projet).
- **Page /inflight** : tout l'encours (colonnes en vol de
l'espace — En cours, QA, Révision par défaut) **tous
projets actifs confondus, regroupé par colonne** — là où
/focus groupe par projet et /my par assignation.
- Chaque carte : projet (lien board), titre, étiquettes,
assigné, et **âge dans la colonne** (badge ⏳, teinté
selon les seuils d'ancienneté) — les groupes sont **triés
par âge décroissant** : les plus vieilles cartes, le vrai
signal, en premier.
- En-tête : total en vol + compteurs par colonne ;
**limite WIP globale** affichée quand des projets en
définissent (somme par colonne).
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
palette Ctrl+K. Rafraîchissement doux par le SSE
`/api/stream` comme les autres vues (fermer une issue la
retire au rechargement).
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
`_card_ages`) : aucun appel forge supplémentaire.
## 0.33.0 — 2026-10-26
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
Demande de Bruno : retrouver vite une issue parmi toutes
celles qui lui sont assignées (467 ouvertes au moment du
test réel).
- **Barre de filtres** en carte, dans la langue visuelle du
compositeur 0.31.1 : recherche texte plein champ
(titre, projet, #numéro, étiquettes — insensible aux
accents et à la casse, raccourci « / », Échap pour
effacer), listes **Projet** et **Étiquette** avec
compteurs, **chips de colonnes** colorées avec compteurs
(multi-sélection), **tri** (activité récente, plus
anciennes, titre, projet).
- **Performance** : filtrage 100 % côté client sur des
attributs précalculés côté serveur — aucun aller-retour
réseau par frappe, application en requestAnimationFrame.
Compteur en direct (« 24 sur 467 issues »), compteurs de
section mis à jour, état vide propre avec réinitialisation
en un clic.
- **Persistance** : projet, colonnes, étiquette et tri sont
retenus en localStorage d'une visite à l'autre (la
recherche texte reste éphémère) ; bouton « Réinitialiser
les filtres » dès qu'un filtre est actif.
## 0.32.2 — 2026-10-25
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
Sur kanbante-dev, le mode console du panneau était une page morte
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
en `-dev`, le panneau recevait donc le défaut prod
`https://agent.dracodev.net` : iframe de console pointant un agent
prod pas encore installé, donc injoignable. Et aucune résolution
ne tenait compte de la requête : sans variable d'environnement,
un hôte public en `-dev` retombait entièrement sur la prod.
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
énoncée nulle part.
- **Résolution par requête** (`resoudre_urls_draco` dans
`app/config.py`, consommée par `render()` dans `app/main.py`)
pour tout ce qui est rendu à une requête — liens de la barre
(`draco_urls`), source de l'iframe du panneau
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
l'ordre : 1) la variable explicite de l'installation,
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
toutes les sœurs et à l'agent — jamais de suffixe inventé
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
suivante ; 3) la dérivation par les adresses de l'agent connues
en configuration, **élargie** : URL d'événements **ou** base du
service, selon ce que l'installation déclare ; 4) le défaut
officiel.
- **Base de l'agent dérivée** (contextes sans requête aussi) :
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
lieu de rester au défaut prod.
- **CSP nouvelle** : chaque page rendue énonce
`Content-Security-Policy: frame-src 'self' <origine agent>` —
exactement l'origine résolue pour cette même réponse, celle de
l'iframe de la console (seule iframe de l'application).
- Les contextes sans requête gardent la résolution par
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
n'en a pas par conception ; périmètre limité à ses liens et à
son panneau.
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
journal et dérivation élargie à la base du service ; contextes
sans requête inchangés. Suite complète : 278 tests réussis.
- **Déploiement** : cette version part avec le prochain
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
preuve en direct **en attente du redéploiement Docker de
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
## 0.32.1 — 2026-10-25
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
Sur la dev, le menu inter-apps pointait vers les noms **prod**
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
défaut officiel faute de variable explicite — il aurait fallu
déclarer quatre variables de plus dans le `.env` dev.
- **Résolution des URL sœurs** dans `app/config.py` (les
gabarits et le JS de la barre consomment `draco_urls`
inchangé), dans l'ordre : 1) la variable explicite —
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
d'environnement** depuis l'origine de l'agent telle que
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
deviné ; 3) le défaut officiel (prod), comme avant.
- L'URL Gitea ne change pas.
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
quatre URL sœurs servies sont en `-dev` ; une URL explicite
gagne sur la dérivation ; hôte d'agent hors motif et aucun
environnement posé restent aux défauts officiels
(non-régression).
## 0.32.0 — 2026-10-25
### Refactor — noms publics dracodev par configuration (dev/prod)
Aucun nom public dracodev n'est plus codé en dur dans le code
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
dont les défauts sont les noms **officiels** (prod). L'environnement
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
`AGENT_URL` quand il n'est pas posé explicitement (l'override
compose existant garde la priorité).
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
viennent de `draco_urls` injecté par `render()` ; le panneau
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
injecté avant `draco-panneau.js` (chargé avec le cache-busting
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
aucun nom : sans injection, il se déclare non configuré.
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
est construit depuis la configuration (`sso_authorize_url`).
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
document d'installation dev affirmant « pas de SSO par conception »
pour Kanbante — comportement existant conservé tel quel, juste
rendu configurable ; la contradiction documentaire est signalée,
pas tranchée ici.
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
- Tests barre/SSO mis en cohérence via la configuration (défauts
officiels + variantes `-dev`), `.env.example` créé, README et
`docker-compose.yml` documentent chaque variable touchée.
## 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)
+6
View File
@@ -111,6 +111,12 @@ sudo systemctl enable --now docker
|---|---|---|
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea |
| `GITEA_TOKEN` | *(vide)* | Jeton API Gitea — **portée conseillée : lecture + écriture des issues, sur un compte dédié, pas un jeton admin**. Sans jeton : contenu public seulement |
| `KANBANTE_PUBLIC_URL` | `https://kanbante.dracodev.net` | Propre URL publique de Kanbante (lien de la barre commune, `return_to` du lien SSO) — noms publics par configuration : défauts = noms officiels (prod), la dev déclare ses noms en `-dev` (ex. `https://kanbante-dev.dracodev.net`) |
| `DRACO_SSO_URL` | `https://og.dracodev.net` | Base d'ObsiGate, émettrice SSO (lien de barre ObsiGate, révocations, lien de connexion SSO) |
| `DRACO_FLOWDECK_URL` / `DRACO_TIKTAK_URL` | `https://flowdeck.dracodev.net` / `https://tiktak.dracodev.net` | Liens Flowdeck / TikTak de la barre commune |
| `AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents (panneau Spark + console, adresse injectée au JS par l'hôte) — sans variable explicite, dérivée de l'hôte d'`AGENT_EVENEMENTS_URL` s'il suit `agent<SUFFIXE>.dracodev.net` ; à chaque requête, l'hôte public (motif fermé `*.dracodev.net`) détermine l'environnement des URL rendues sauf variable explicite (0.32.2) |
| `AGENT_EVENEMENTS_URL` | *(dérivé de `AGENT_URL`)* | Journal d'événements du service d'agents ; ne se pose que pour surcharger la dérivation `<AGENT_URL>/api/v1/evenements` |
| `AGENT_EVENEMENTS_CLE` | *(vide)* | Clé HMAC signant les événements émis vers ce journal ; vide = émission désactivée |
| `KANBANTE_DB_PATH` | `~/.kanbante/kanbante.db` | Base SQLite locale (en Docker : `/data/kanbante.db`) |
| `KANBANTE_AUTH_USER` / `KANBANTE_AUTH_PASSWORD` | *(vide)* | Active l'authentification dès que les deux sont définis : écran de connexion + session signée pour les navigateurs ; l'en-tête Basic reste accepté pour les scripts |
| `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
+136
View File
@@ -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))
+254 -7
View File
@@ -96,10 +96,72 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
AGENT_URL Base URL of the agent service
(panneau Spark + console). Default
(official/prod name)
https://agent.dracodev.net — the dev
environment declares its ``-dev`` name
here (e.g. https://agent-dev.dracodev.net).
Sans variable explicite, la base est
aussi dérivée de l'hôte de
AGENT_EVENEMENTS_URL quand il suit le
motif ``agent<SUFFIXE>.dracodev.net``
(0.32.2 : sinon le panneau pointait le
défaut prod alors que le journal était
en dev — console morte sur la dev).
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.
Default: derived from AGENT_URL
(``<AGENT_URL>/api/v1/evenements``).
Son hôte sert aussi de source à la
dérivation d'environnement des URL
sœurs ci-dessous (voir
``_url_soeur_draco``), au même titre
que la base du service (AGENT_URL) —
dérivation élargie en 0.32.2.
KANBANTE_PUBLIC_URL This app's own public base URL
(barre commune self-link, SSO
``return_to``). Default (official)
https://kanbante.dracodev.net.
DRACO_FLOWDECK_URL Public base URL of Flowdeck for the
common app bar. Default (official)
https://flowdeck.dracodev.net.
DRACO_TIKTAK_URL Public base URL of TikTak for the
common app bar. Default (official)
https://tiktak.dracodev.net.
ObsiGate's bar link reuses DRACO_SSO_URL
(the SSO emitter IS ObsiGate).
Résolution d'une URL rendue à une
requête (barre, panneau, CSP), dans
l'ordre (voir ``resoudre_urls_draco``,
0.32.2) : 1) la variable explicite
ci-dessus (prioritaire, contrat
inchangé) ; 2) l'hôte public de la
requête, motif fermé
``(og|flowdeck|tiktak|kanbante|agent)
<SUFFIXE>.dracodev.net`` (suffixe vide
en prod, ``-dev`` en dev) appliqué à
toutes les sœurs et à l'agent — jamais
de suffixe inventé depuis un hôte
arbitraire ; 3) dérivation depuis les
adresses de l'agent connues en
configuration (AGENT_EVENEMENTS_URL
ou AGENT_URL) — si cet hôte est
``agent<SUFFIXE>.dracodev.net``,
l'URL sœur devient
``https://<slug><SUFFIXE>.
dracodev.net`` (slugs : og, flowdeck,
tiktak, kanbante, agent) ; hôte hors
motif (autre domaine, LAN, IP) =
aucune dérivation, jamais de lien
deviné ; 4) le défaut officiel (prod).
Les contextes sans requête gardent la
résolution par configuration (étapes
1, 3 et 4), inchangée sinon.
No public dracodev name is hard-coded
outside these defaults: dev/prod differ
only by environment, never by code.
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the
service knows it as the "kanbante"
@@ -108,12 +170,14 @@ Environment variables:
"""
import os
import re
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.28.0"
APP_VERSION = "0.37.1"
def _env_bool(name: str, default: bool) -> bool:
@@ -149,6 +213,144 @@ def _env_wip_limits(name: str) -> dict[str, int]:
return limits
# ── Résolution d'environnement des URL publiques dracodev ──
# 0.32.1 : sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte
# agent-dev.dracodev.net) : les URL sœurs retombaient donc sur leurs
# défauts prod dans la barre commune. La dérivation depuis l'hôte de
# l'agent évite de devoir déclarer quatre variables de plus.
# 0.32.2 — couche fautive corrigée : cette dérivation ne couvrait
# que les URL sœurs, jamais ``agent_url`` lui-même — le panneau
# recevait donc le défaut prod (agent injoignable, la prod n'étant
# pas installée) et la console était une page morte sur la dev.
# Et aucune résolution ne tenait compte de la requête : un hôte
# public en ``-dev`` sans aucune variable retombait sur la prod.
# Désormais, pour tout ce qui est rendu à une requête (liens de la
# barre / DRACO_URLS, source de l'iframe du panneau, origine agent
# de la CSP) : 1) variable explicite ; 2) hôte public de la
# requête (motif fermé ci-dessous) ; 3) dérivation par les adresses
# de l'agent connues en configuration — URL d'événements OU base
# du service (élargie en 0.32.2) ; 4) défaut officiel.
_MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net")
_MOTIF_HOTE_PUBLIC = re.compile(
r"(?:og|flowdeck|tiktak|kanbante|agent)([a-z0-9-]*)\.dracodev\.net"
)
def _suffixe_depuis_hote(hote: str, motif: re.Pattern) -> str | None:
"""Suffixe d'environnement porté par un hôte nu, ou ``None``.
Retourne le suffixe (``""`` pour le nom officiel, ``"-dev"``
pour la dev) si l'hôte suit le motif fermé ; ``None`` hors
motif (autre domaine, LAN, IP…) — aucune dérivation alors,
jamais de suffixe inventé depuis un hôte arbitraire.
"""
correspondance = motif.fullmatch(hote.strip().lower())
return correspondance.group(1) if correspondance else None
def _suffixe_environnement_agent(url_agent: str) -> str | None:
"""Suffixe lu sur l'hôte d'une adresse connue de l'agent.
Vaut pour l'URL d'événements comme pour la base du service :
si son hôte est ``agent<SUFFIXE>.dracodev.net``, le suffixe
est retourné ; ``None`` hors motif.
"""
try:
hote = urlparse(url_agent).hostname or ""
except ValueError:
return None
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_AGENT)
def suffixe_environnement_requete(hote_requete: str | None) -> str | None:
"""Suffixe lu sur l'hôte public de la requête entrante.
Motif fermé ``^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>
\\.dracodev\\.net$`` (suffixe optionnel : vide en prod,
``-dev`` en dev) ; un port éventuel est toléré et retiré.
Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant
retombe alors sur la configuration, jamais sur un suffixe
deviné.
"""
if not hote_requete:
return None
hote = hote_requete.strip().lower()
if hote.startswith("["): # littéral IPv6 : jamais dans le motif
return None
hote = hote.split(":", 1)[0]
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_PUBLIC)
def _url_soeur_draco(
nom_env: str, slug: str, defaut: str, suffixe: str | None
) -> str:
"""Résout l'URL publique d'une app sœur de la barre commune.
1) ``nom_env`` explicite — prioritaire, contrat inchangé ;
2) dérivation d'environnement : ``https://<slug><suffixe>.
dracodev.net`` quand l'hôte de l'agent suit le motif ;
3) défaut officiel (prod), comme avant.
"""
explicite = os.getenv(nom_env)
if explicite:
return explicite.rstrip("/")
if suffixe is not None:
return f"https://{slug}{suffixe}.dracodev.net"
return defaut
@dataclass(frozen=True)
class UrlsDraco:
"""URL publiques dracodev résolues pour un rendu ou un contexte."""
sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL)
flowdeck_url: str
tiktak_url: str
public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL)
agent_url: str # base du service d'agents (panneau + CSP)
def resoudre_urls_draco(
hote_requete: str | None = None, base: "Settings | None" = None
) -> UrlsDraco:
"""Résout les URL publiques pour un rendu à une requête.
Ordre, par URL : 1) la variable explicite de l'installation —
prioritaire, inchangée (``DRACO_SSO_URL`` pour ObsiGate,
``KANBANTE_PUBLIC_URL`` pour Kanbante elle-même, ``AGENT_URL``
pour l'agent) ; 2) l'hôte public de la requête, s'il suit le
motif fermé dracodev : son suffixe d'environnement (``-dev``,
vide en prod) s'applique à toutes les sœurs et à l'agent ;
3) à défaut, la valeur de configuration ``base`` (défaut :
``_env_settings()``), qui porte déjà la dérivation par les
adresses de l'agent connues (événements ou base du service)
puis le défaut officiel — c'est aussi la résolution des
contextes sans requête, inchangée.
"""
if base is None:
base = _env_settings()
suffixe_hote = suffixe_environnement_requete(hote_requete)
def resoudre(nom_env: str, slug: str, valeur_base: str) -> str:
if os.getenv(nom_env):
return valeur_base # explicite : déjà résolue dans base
if suffixe_hote is not None:
return f"https://{slug}{suffixe_hote}.dracodev.net"
return valeur_base
return UrlsDraco(
sso_url=resoudre("DRACO_SSO_URL", "og", base.sso_url),
flowdeck_url=resoudre(
"DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url
),
tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url),
public_url=resoudre(
"KANBANTE_PUBLIC_URL", "kanbante", base.public_url
),
agent_url=resoudre("AGENT_URL", "agent", base.agent_url),
)
@dataclass(frozen=True)
class Settings:
gitea_url: str = "https://git.dracodev.net"
@@ -188,6 +390,10 @@ class Settings:
draco_secret_prev: str = ""
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
public_url: str = "https://kanbante.dracodev.net"
flowdeck_url: str = "https://flowdeck.dracodev.net"
tiktak_url: str = "https://tiktak.dracodev.net"
agent_url: str = "https://agent.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@@ -205,6 +411,29 @@ def get_settings() -> Settings:
def _env_settings() -> Settings:
"""Build settings from the current environment only."""
agent_url_explicite = os.getenv("AGENT_URL")
agent_url = (
agent_url_explicite or "https://agent.dracodev.net"
).rstrip("/")
agent_evenements_url = (
os.getenv("AGENT_EVENEMENTS_URL")
or f"{agent_url}/api/v1/evenements"
).rstrip("/")
# Origines de l'agent telles que l'app les connaît déjà : elles
# portent le suffixe d'environnement (-dev…) repris par les URL
# sœurs dépourvues de variable explicite. Dérivation élargie
# (0.32.2) : l'URL d'événements d'abord, puis la base du service
# elle-même — selon ce que l'installation déclare.
suffixe_agent = _suffixe_environnement_agent(agent_evenements_url)
if suffixe_agent is None:
suffixe_agent = _suffixe_environnement_agent(agent_url)
# Base du service dérivée à son tour (0.32.2) : sans AGENT_URL
# explicite, si l'adresse connue de l'agent est en ``-dev``, le
# panneau et la console doivent viser cette même origine —
# auparavant agent_url restait au défaut prod, d'où la console
# morte sur la dev alors que le journal était bien en dev.
if not agent_url_explicite and suffixe_agent is not None:
agent_url = f"https://agent{suffixe_agent}.dracodev.net"
return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -247,12 +476,30 @@ def _env_settings() -> Settings:
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
sso_url=_url_soeur_draco(
"DRACO_SSO_URL", "og", "https://og.dracodev.net", suffixe_agent
),
public_url=_url_soeur_draco(
"KANBANTE_PUBLIC_URL",
"kanbante",
"https://kanbante.dracodev.net",
suffixe_agent,
),
flowdeck_url=_url_soeur_draco(
"DRACO_FLOWDECK_URL",
"flowdeck",
"https://flowdeck.dracodev.net",
suffixe_agent,
),
tiktak_url=_url_soeur_draco(
"DRACO_TIKTAK_URL",
"tiktak",
"https://tiktak.dracodev.net",
suffixe_agent,
),
agent_url=agent_url,
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_url=agent_evenements_url,
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
)
+3 -1
View File
@@ -17,6 +17,8 @@ import logging
import httpx
from app.config import APP_VERSION
logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05"
@@ -143,7 +145,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": APP_VERSION},
},
req_id=1,
)
+135
View File
@@ -48,6 +48,7 @@ class GiteaClient:
"""
kind = "gitea"
supports_attachments = True
api_suffix = "/api/v1"
auth_scheme = "token"
@@ -476,6 +477,59 @@ class GiteaClient:
),
)
async def create_milestone(
self,
owner: str,
repo: str,
title: str,
description: str = "",
due_on: str | None = None,
) -> dict:
"""Create a milestone on the forge (issue #26)."""
payload: dict = {"title": title, "description": description}
if due_on:
payload["due_on"] = due_on
r = await self.client.post(
f"/repos/{owner}/{repo}/milestones", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def update_milestone(
self,
owner: str,
repo: str,
milestone_id: int,
title: str | None = None,
description: str | None = None,
due_on: str | None = None,
clear_due: bool = False,
state: str | None = None,
) -> dict:
"""Patch a milestone (title, due date, state) — #26.
due_on=None + clear_due=False leaves the date alone;
clear_due=True sends an explicit null (Gitea clears it).
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if clear_due:
payload["due_on"] = None
elif due_on:
payload["due_on"] = due_on
if state is not None:
payload["state"] = state
r = await self.client.patch(
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth)."""
@@ -503,6 +557,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:
+4
View File
@@ -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"
+1123 -174
View File
File diff suppressed because it is too large Load Diff
+137 -7
View File
@@ -154,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,
@@ -219,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,
)
@@ -340,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:
@@ -364,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(
@@ -400,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):
@@ -583,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(
@@ -793,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
@@ -1253,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
View File
@@ -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 (
+11 -7
View File
@@ -1,7 +1,10 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
Snippet vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : structure inchangée ; seule adaptation locale
(0.32.0, dev/prod) = les URL des apps et l'adresse du service
d'agents sont INJECTÉES par l'hôte depuis app.config
(draco_urls / agent_url, défauts = noms officiels) — aucun nom
public codé en dur dans ce gabarit ni dans draco-panneau.js.
CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #}
@@ -15,20 +18,20 @@
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<a class="draco-apptab" data-app="obsigate" href="{{ draco_urls.obsigate }}/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<a class="draco-apptab" data-app="flowdeck" href="{{ draco_urls.flowdeck }}/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<a class="draco-apptab" data-app="tiktak" href="{{ draco_urls.tiktak }}/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<a class="draco-apptab" data-app="kanbante" href="{{ draco_urls.kanbante }}/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
@@ -101,4 +104,5 @@
};
</script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></script>
<script>window.DRACO_PANNEAU_CONFIG = { api: "{{ agent_url }}" };</script>
<script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+2
View File
@@ -20,6 +20,7 @@
("digest", "/digest", "🧠", "Digest"),
]),
("pilotage", "Pilotage", [
("inflight", "/inflight", "🛩️", "En vol"),
("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"),
@@ -38,6 +39,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"),
]),
] %}
+99
View File
@@ -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>
+40 -7
View File
@@ -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">
+3 -1
View File
@@ -64,6 +64,7 @@
<label class="dash-tool">Tri
<select id="dash-sort">
<option value="default">Par défaut</option>
<option value="review">🔍 En attente de revue ↓</option>
<option value="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option>
@@ -85,6 +86,7 @@
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
@@ -146,7 +148,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] }}">
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>En vol partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
<div class="inflight-summary">
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
{% for group in groups %}
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
</span>
{% endfor %}
</div>
{% if not groups %}
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2>
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
{{ column_labels[group.column] }}
<span class="badge secondary">{{ group.cards|length }}</span>
{% if wip_globals.get(group.column) %}
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
{% endif %}
</h2>
<div class="focus-cards">
{% for card in group.cards %}
<div class="kanban-card focus-card">
<div class="card-id">
#{{ card.issue.number }}
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
{% if card.issue.html_url %}
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ card.issue.title }}</div>
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if card.issue.assignee %}
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
{% endif %}
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</main>
<footer>
Kanbante v{{ version }} · en vol partout
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -29,7 +29,7 @@
</label>
<button type="submit" class="login-submit">Se connecter</button>
</form>
<a class="login-sso" href="https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fkanbante.dracodev.net%2Fauth%2Fdraco">🧭 Se connecter avec ObsiGate (SSO)</a>
<a class="login-sso" href="{{ sso_authorize_url }}">🧭 Se connecter avec ObsiGate (SSO)</a>
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section>
</main>
+56 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes issues — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
@@ -15,17 +15,68 @@
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
{% else %}
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
{% if groups %}
<div class="my-filters" id="my-filters">
<div class="my-filters-row">
<div class="my-search-wrap">
<span class="my-search-icon" aria-hidden="true">🔍</span>
<input type="search" id="my-search" autocomplete="off"
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
</div>
<select id="my-repo" title="Filtrer par projet">
<option value="">Tous les projets</option>
{% for f in repo_facets %}
<option value="{{ f.full_name }}">{{ f.full_name }} ({{ f.count }})</option>
{% endfor %}
</select>
<select id="my-label" title="Filtrer par étiquette">
<option value="">Toutes les étiquettes</option>
{% for f in label_facets %}
<option value="{{ f.name }}">{{ f.name }} ({{ f.count }})</option>
{% endfor %}
</select>
<select id="my-sort" title="Trier les résultats">
<option value="recent">Activité récente d'abord</option>
<option value="oldest">Plus anciennes d'abord</option>
<option value="title">Titre (A → Z)</option>
<option value="repo">Projet (A → Z)</option>
</select>
</div>
<div class="my-filters-row my-filters-meta">
<div class="my-col-chips" id="my-col-chips" role="group" aria-label="Filtrer par colonne">
<button type="button" class="filter-chip on" data-col="">Toutes les colonnes</button>
{% for f in column_facets %}
<button type="button" class="filter-chip" data-col="{{ f.key }}" aria-pressed="false">
<span class="filter-chip-dot" style="background: {{ column_colors[f.key] }}"></span>{{ column_labels[f.key] }} <span class="filter-chip-count">{{ f.count }}</span>
</button>
{% endfor %}
</div>
<span class="my-count" id="my-count" role="status"></span>
<button type="button" id="my-reset" class="btn-sm" hidden>↺ Réinitialiser les filtres</button>
</div>
</div>
{% endif %}
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
</div>
{% if not groups %}
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<section class="focus-group" data-repo="{{ group.full_name }}">
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
<span class="badge secondary">{{ group.rows|length }}</span></h2>
<span class="badge secondary" data-group-count>{{ group.rows|length }}</span></h2>
<div class="issue-results">
{% for row in group.rows %}
{% set issue = row.issue %}
<div class="issue-row">
<div class="issue-row"
data-column="{{ row.column }}"
data-labels="{{ (issue.labels or [])|map(attribute='name')|join(' ') }}"
data-updated="{{ issue.updated_at or '' }}"
data-title="{{ issue.title }}"
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
<span class="issue-state">🟢</span>
<a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
@@ -46,6 +97,7 @@
<footer>
Kanbante v{{ version }} · mes issues
</footer>
<script src="/static/js/my.js?v={{ version }}" defer></script>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
+33 -2
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="repo-full-name" content="{{ full_name }}">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
@@ -51,11 +53,39 @@
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
{% if ms.id %}
<details class="ms-edit">
<summary>✏️ Modifier / fermer</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
<span class="ms-hint">vide = retirer l'échéance</span></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
</div>
</form>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
</form>
</details>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
{% endif %}
<details class="ms-edit ms-new">
<summary>➕ Nouveau jalon</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
<label>Échéance <input type="date" name="due_on"></label>
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
</div>
</form>
</details>
</section>
<section class="proj-panel">
@@ -124,6 +154,7 @@
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+9
View File
@@ -6,6 +6,15 @@ services:
- "8080:8080"
environment:
- GITEA_URL=${GITEA_URL:-https://git.dracodev.net}
# Noms publics dracodev (dev/prod) : défauts = noms officiels ;
# l'environnement dev déclare ses noms en -dev dans son .env.
- KANBANTE_PUBLIC_URL=${KANBANTE_PUBLIC_URL:-https://kanbante.dracodev.net}
- DRACO_SSO_URL=${DRACO_SSO_URL:-https://og.dracodev.net}
- DRACO_FLOWDECK_URL=${DRACO_FLOWDECK_URL:-https://flowdeck.dracodev.net}
- DRACO_TIKTAK_URL=${DRACO_TIKTAK_URL:-https://tiktak.dracodev.net}
- AGENT_URL=${AGENT_URL:-https://agent.dracodev.net}
- AGENT_EVENEMENTS_URL=${AGENT_EVENEMENTS_URL:-}
- AGENT_EVENEMENTS_CLE=${AGENT_EVENEMENTS_CLE:-}
- GITEA_TOKEN=${GITEA_TOKEN}
- KANBANTE_DB_PATH=/data/kanbante.db
# Strongly recommended: without these, the app is unauthenticated.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.28.0"
version = "0.37.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+67
View File
@@ -1683,3 +1683,70 @@ 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; }
/* ── Mes issues : barre de filtres (0.33.0) ── */
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.my-filters-row + .my-filters-row { margin-top: 10px; }
.my-search-wrap { position: relative; flex: 1 1 280px; }
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
.my-filters select:focus { outline: none; border-color: var(--accent); }
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.filter-chip:hover { border-color: var(--border-strong); }
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
/* ── En vol partout (0.34.0, issue #24) ── */
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
.inflight-total { font-weight: 700; margin-right: 4px; }
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
.ms-edit { margin-top: 8px; }
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
.ms-edit summary::-webkit-details-marker { display: none; }
.ms-edit summary:hover { text-decoration: underline; }
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
.ms-form-actions { display: flex; gap: 8px; }
+153 -6
View File
@@ -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';
+8
View File
@@ -32,6 +32,7 @@
function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
if (mode === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien
@@ -52,6 +53,13 @@
if (isNaN(tb)) return -1;
return tb - ta;
}
// Modes numériques (open, review) : les clés sont
// des compteurs niés (-n) — ka - kb donne donc le
// plus grand compteur en premier, comme l'annonce
// le « ↓ » des options. (Avant 0.37.0, la
// comparaison générique ci-dessous inversait ces
// deux tris : ils ressortaient croissants.)
if (mode === 'open' || mode === 'review') return ka - kb;
if (ka < kb) return mode === 'name' ? -1 : 1;
if (ka > kb) return mode === 'name' ? 1 : -1;
return 0;
+183 -3
View File
@@ -18,17 +18,32 @@
* 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 (adresse injectée par l'hôte) 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";
/* Adresse du service : injectée par l'app hôte (gabarit
* _draco_bar.html, window.DRACO_PANNEAU_CONFIG.api, issu de la
* configuration de l'hôte — AGENT_URL). Aucun nom public en dur
* ici : sans injection, le panneau se déclare non configuré. */
var API = (window.DRACO_PANNEAU_CONFIG && window.DRACO_PANNEAU_CONFIG.api) || "";
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;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -44,6 +59,124 @@
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");
@@ -57,6 +190,7 @@
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
@@ -122,6 +256,7 @@
' <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 en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
@@ -134,6 +269,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -142,9 +278,11 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
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);
@@ -155,7 +293,13 @@
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
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();
});
}
@@ -189,8 +333,43 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
if (!API) {
majEtat("err", "Panneau Spark non configuré");
ajouterMsg("sys", "Adresse du service d'agents absente : l'app hôte ne l'a pas injectée (DRACO_PANNEAU_CONFIG / AGENT_URL).");
etatConnu = true;
return;
}
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
@@ -209,7 +388,7 @@
})
.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é.");
ajouterMsg("sys", "Le service d'agents (" + API + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
@@ -665,6 +844,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+190
View File
@@ -0,0 +1,190 @@
// Mes issues — filtrage instantané côté client (0.33.0).
// Aucune requête réseau : les lignes portent leurs facettes en
// attributs data-*, le moteur ne fait que comparer des chaînes
// normalisées. Les filtres structurels (projet, colonnes,
// étiquette, tri) persistent en localStorage ; la recherche
// texte, elle, est éphémère.
(() => {
const search = document.getElementById('my-search');
if (!search) return; // page sans résultats : rien à filtrer
const repoSel = document.getElementById('my-repo');
const labelSel = document.getElementById('my-label');
const sortSel = document.getElementById('my-sort');
const chipsBox = document.getElementById('my-col-chips');
const countEl = document.getElementById('my-count');
const resetBtn = document.getElementById('my-reset');
const emptyBox = document.getElementById('my-empty');
const emptyReset = document.getElementById('my-empty-reset');
const STORE_KEY = 'kanbante-my-filters';
const norm = (s) => (s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const state = {
q: '',
repo: '',
label: '',
cols: new Set(),
sort: 'recent',
};
// ── Persistance des filtres structurels ────────────────
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
if (saved.repo) state.repo = saved.repo;
if (saved.label) state.label = saved.label;
if (Array.isArray(saved.cols)) state.cols = new Set(saved.cols);
if (saved.sort) state.sort = saved.sort;
} catch { /* stockage indisponible : état par défaut */ }
function persist() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify({
repo: state.repo,
label: state.label,
cols: [...state.cols],
sort: state.sort,
}));
} catch { /* sans gravité */ }
}
// Refléter l'état restauré dans les contrôles.
if (state.repo) repoSel.value = state.repo;
if (state.label) labelSel.value = state.label;
sortSel.value = state.sort;
// Un filtre restauré peut ne plus exister (projet disparu) :
// dans ce cas le select retombe à « tous » tout seul.
if (repoSel.value !== state.repo) state.repo = repoSel.value;
if (labelSel.value !== state.label) state.label = labelSel.value;
const groups = [...document.querySelectorAll('.focus-group')];
const allRows = [...document.querySelectorAll('.issue-row')];
allRows.forEach((row) => {
row._search = norm(row.dataset.search);
row._labels = norm(row.dataset.labels).split(' ').filter(Boolean);
row._title = norm(row.dataset.title);
});
function syncChips() {
chipsBox.querySelectorAll('.filter-chip').forEach((chip) => {
const col = chip.dataset.col;
const on = col === '' ? state.cols.size === 0 : state.cols.has(col);
chip.classList.toggle('on', on);
if (col !== '') chip.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
function sortRows() {
const mode = state.sort;
if (mode === 'repo') {
const main = document.querySelector('.focus-main');
[...groups].sort((a, b) =>
a.dataset.repo.localeCompare(b.dataset.repo)
).forEach((g) => main.appendChild(g));
}
groups.forEach((group) => {
const box = group.querySelector('.issue-results');
const rows = [...box.querySelectorAll('.issue-row')];
const by = {
recent: (a, b) => (b.dataset.updated || '').localeCompare(a.dataset.updated || ''),
oldest: (a, b) => (a.dataset.updated || '').localeCompare(b.dataset.updated || ''),
title: (a, b) => a._title.localeCompare(b._title),
repo: (a, b) => a._title.localeCompare(b._title),
}[mode];
rows.sort(by).forEach((r) => box.appendChild(r));
});
}
function apply() {
const q = norm(state.q).trim();
let visible = 0;
groups.forEach((group) => {
let groupVisible = 0;
group.querySelectorAll('.issue-row').forEach((row) => {
const okText = !q || row._search.includes(q);
const okRepo = !state.repo || group.dataset.repo === state.repo;
const okCol = state.cols.size === 0 || state.cols.has(row.dataset.column);
const okLabel = !state.label
|| row._labels.includes(norm(state.label));
const ok = okText && okRepo && okCol && okLabel;
row.hidden = !ok;
if (ok) groupVisible++;
});
group.hidden = groupVisible === 0;
const badge = group.querySelector('[data-group-count]');
if (badge) badge.textContent = groupVisible;
visible += groupVisible;
});
countEl.textContent = visible === allRows.length
? `${visible} issue${visible > 1 ? 's' : ''}`
: `${visible} sur ${allRows.length} issues`;
const filtering = q || state.repo || state.label
|| state.cols.size > 0;
resetBtn.hidden = !filtering;
emptyBox.hidden = visible !== 0;
syncChips();
persist();
}
function resetAll() {
state.q = '';
search.value = '';
state.repo = '';
repoSel.value = '';
state.label = '';
labelSel.value = '';
state.cols.clear();
sortSel.value = 'recent';
state.sort = 'recent';
sortRows();
apply();
search.focus();
}
// ── Événements ─────────────────────────────────────────
let raf = 0;
search.addEventListener('input', () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
state.q = search.value;
apply();
});
});
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
search.value = '';
state.q = '';
apply();
}
});
repoSel.addEventListener('change', () => { state.repo = repoSel.value; apply(); });
labelSel.addEventListener('change', () => { state.label = labelSel.value; apply(); });
sortSel.addEventListener('change', () => {
state.sort = sortSel.value;
sortRows();
apply();
});
chipsBox.addEventListener('click', (e) => {
const chip = e.target.closest('.filter-chip');
if (!chip) return;
const col = chip.dataset.col;
if (col === '') state.cols.clear();
else if (state.cols.has(col)) state.cols.delete(col);
else state.cols.add(col);
apply();
});
resetBtn.addEventListener('click', resetAll);
emptyReset?.addEventListener('click', resetAll);
document.addEventListener('keydown', (e) => {
if (e.key !== '/') return;
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
search.focus();
});
sortRows();
apply();
})();
+1
View File
@@ -13,6 +13,7 @@
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '🛩️', label: 'En vol partout — encours tous projets, par colonne', href: '/inflight' },
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
+27
View File
@@ -0,0 +1,27 @@
// Page Projet — écritures de jalons (0.35.0, issue #26).
// Les formulaires data-ms-form sont interceptés : POST vers
// l'API Kanbante (qui écrit dans la forge), puis rechargement
// pour refléter l'état réel.
(() => {
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
document.querySelectorAll('form[data-ms-form]').forEach((form) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const confirmMsg = form.dataset.confirm;
if (confirmMsg && !window.confirm(confirmMsg)) return;
const fd = new FormData(form);
fd.append('csrf_token', csrf);
const btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
try {
const r = await fetch(form.dataset.action, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
window.location.reload();
} catch (err) {
if (btn) btn.disabled = false;
window.alert('❌ ' + err.message);
}
});
});
})();
+101 -6
View File
@@ -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), []))
@@ -313,15 +359,64 @@ class FakeGitea:
else:
self.starred.discard(f"{owner}/{repo}")
async def get_milestones(self, owner, repo, state="open"):
return [
@property
def _milestones(self):
return self.__dict__.setdefault(
"milestones_store",
{
"id": 1, "title": "Sprint 1",
"due_on": "2026-10-20T00:00:00Z",
"open_issues": 1, "closed_issues": 1,
}
"Projets/demo": [
{
"id": 1, "title": "Sprint 1",
"description": "",
"due_on": "2026-10-20T00:00:00Z",
"state": "open",
"open_issues": 1, "closed_issues": 1,
}
]
},
)
async def get_milestones(self, owner, repo, state="open"):
items = self._milestones.get(f"{owner}/{repo}", [])
return [
dict(m) for m in items
if state == "all" or m.get("state", "open") == state
]
async def create_milestone(
self, owner, repo, title, description="", due_on=None
):
store = self._milestones.setdefault(f"{owner}/{repo}", [])
milestone = {
"id": 100 + len(store),
"title": title,
"description": description,
"due_on": due_on,
"state": "open",
"open_issues": 0, "closed_issues": 0,
}
store.append(milestone)
return dict(milestone)
async def update_milestone(
self, owner, repo, milestone_id, title=None, description=None,
due_on=None, clear_due=False, state=None,
):
for m in self._milestones.get(f"{owner}/{repo}", []):
if m["id"] == milestone_id:
if title is not None:
m["title"] = title
if description is not None:
m["description"] = description
if clear_due:
m["due_on"] = None
elif due_on:
m["due_on"] = due_on
if state is not None:
m["state"] = state
return dict(m)
raise AssertionError("jalon introuvable dans le faux")
async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1
issue = _issue(self._next_number, title)
+359 -1
View File
@@ -15,10 +15,171 @@ def test_barre_presente_sur_le_dashboard(client):
assert "draco-barre.css" in r.text
assert "draco-barre.js" in r.text
assert 'window.DRACO_BAR_CONFIG' in r.text
# Les quatre applications de la barre, aux URL publiques.
# Les quatre applications de la barre, aux URL publiques par
# défaut (noms officiels/prod, issus de la configuration).
for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/",
"https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"):
assert url in r.text
# L'adresse du service d'agents est injectée au panneau.
assert 'window.DRACO_PANNEAU_CONFIG = { api: "https://agent.dracodev.net" }' in r.text
def test_barre_et_panneau_urls_par_configuration(monkeypatch, fake_gitea):
# Dev/prod (0.32.0) : la dev déclare ses noms en -dev par
# l'environnement ; aucun nom public n'est codé en dur.
from fastapi.testclient import TestClient
import app.main as main
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv("DRACO_FLOWDECK_URL", "https://flowdeck-dev.dracodev.net")
monkeypatch.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
monkeypatch.setenv("KANBANTE_PUBLIC_URL", "https://kanbante-dev.dracodev.net")
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
with TestClient(main.app) as client:
r = client.get("/")
assert r.status_code == 200
for url in ("https://og-dev.dracodev.net/",
"https://flowdeck-dev.dracodev.net/",
"https://tiktak-dev.dracodev.net/",
"https://kanbante-dev.dracodev.net/"):
assert url in r.text
assert 'api: "https://agent-dev.dracodev.net"' in r.text
# Le JS statique du panneau ne contient aucun nom en dur :
# il lit l'adresse injectée par l'hôte.
js = client.get("/static/js/draco-panneau.js").text
assert "DRACO_PANNEAU_CONFIG" in js
assert "dracodev.net" not in js
def test_config_agent_evenements_derive_de_agent_url(monkeypatch):
from app.config import _env_settings
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert s.agent_evenements_url == (
"https://agent-dev.dracodev.net/api/v1/evenements"
)
# L'URL d'événements explicite garde la priorité (override compose).
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://exemple.invalid/journal"
)
assert _env_settings().agent_evenements_url == (
"https://exemple.invalid/journal"
)
# ── Dérivation d'environnement des URL sœurs (0.32.1) ──
# Sur la dev, seul AGENT_EVENEMENTS_URL est posé : les URL sœurs
# doivent suivre son suffixe (-dev) sans quatre variables de plus.
_ENV_URLS_SOEURS = (
"DRACO_SSO_URL", # ObsiGate (émettrice SSO)
"DRACO_FLOWDECK_URL",
"DRACO_TIKTAK_URL",
"KANBANTE_PUBLIC_URL", # Kanbante elle-même
"AGENT_URL",
)
_DEFAUTS_OFFICIELS = (
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
)
_URLS_DEV = (
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
)
def _nettoyer_env_soeurs(monkeypatch):
for nom in _ENV_URLS_SOEURS:
monkeypatch.delenv(nom, raising=False)
def _urls_soeurs(settings):
return (
settings.sso_url,
settings.flowdeck_url,
settings.tiktak_url,
settings.public_url,
)
def test_config_urls_soeurs_derivees_du_seul_agent_evenements(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
assert _urls_soeurs(_env_settings()) == _URLS_DEV
def test_barre_urls_soeurs_derivees_du_seul_agent_evenements(
monkeypatch, fake_gitea
):
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
with TestClient(main.app) as client:
r = client.get("/")
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
def test_config_url_soeur_explicite_gagne_sur_la_derivation(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
monkeypatch.setenv(
"DRACO_FLOWDECK_URL", "https://flowdeck.example.test"
)
s = _env_settings()
assert s.flowdeck_url == "https://flowdeck.example.test"
# Les autres sœurs restent dérivées de l'environnement.
assert s.sso_url == "https://og-dev.dracodev.net"
assert s.tiktak_url == "https://tiktak-dev.dracodev.net"
assert s.public_url == "https://kanbante-dev.dracodev.net"
def test_config_hote_agent_hors_motif_reste_aux_defauts(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
for url_agent in (
"https://agent.example.com/api/v1/evenements",
"https://agent-dev.autre-domaine.net/api/v1/evenements",
"http://192.168.30.101:8790/api/v1/evenements",
"http://127.0.0.1:8790/api/v1/evenements",
):
monkeypatch.setenv("AGENT_EVENEMENTS_URL", url_agent)
assert _urls_soeurs(_env_settings()) == _DEFAUTS_OFFICIELS
def test_config_aucun_env_reste_aux_defauts_officiels(monkeypatch):
from app.config import _env_settings
_nettoyer_env_soeurs(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
assert _urls_soeurs(_env_settings()) == _DEFAUTS_OFFICIELS
def test_statiques_de_la_barre_servis(client):
@@ -62,3 +223,200 @@ def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea):
r = client.get("/login")
assert r.status_code == 200
assert "draco-bar" not in r.text
# ── Résolution par requête et base de l'agent dérivée (0.32.2) ──
# Couche fautive corrigée : la dérivation 0.32.1 ne couvrait que les
# URL sœurs depuis AGENT_EVENEMENTS_URL — jamais agent_url, donc le
# panneau visait le défaut prod (agent injoignable : la prod n'est
# pas installée) et la console était une page morte sur la dev.
# Aucune résolution ne tenait compte de l'hôte de la requête, et
# aucune CSP n'énonçait l'origine de l'agent.
_ENV_RESOLUTION = _ENV_URLS_SOEURS + ("AGENT_EVENEMENTS_URL",)
def _nettoyer_env_resolution(monkeypatch):
for nom in _ENV_RESOLUTION:
monkeypatch.delenv(nom, raising=False)
def test_config_base_agent_derivee_du_seul_agent_evenements(monkeypatch):
# Le cas réel de la dev : seul le journal est déclaré en -dev.
# La base du service (panneau/console) doit suivre, sinon
# l'iframe de la console charge l'agent prod, injoignable.
from app.config import _env_settings
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert _urls_soeurs(s) == _URLS_DEV
def test_config_derivation_elargie_a_la_base_du_service(monkeypatch):
# Élargie : si l'URL d'événements est hors motif mais que la
# base du service suit le motif, les sœurs dérivent de la base.
from app.config import _env_settings
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv("AGENT_URL", "https://agent-dev.dracodev.net")
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://journal.example.test/evenements"
)
s = _env_settings()
assert s.agent_url == "https://agent-dev.dracodev.net"
assert s.agent_evenements_url == (
"https://journal.example.test/evenements"
)
assert _urls_soeurs(s) == _URLS_DEV
def test_suffixe_environnement_requete_motif_ferme():
from app.config import suffixe_environnement_requete
assert suffixe_environnement_requete("kanbante-dev.dracodev.net") == "-dev"
assert suffixe_environnement_requete("kanbante.dracodev.net") == ""
assert suffixe_environnement_requete("og-dev.dracodev.net") == "-dev"
assert suffixe_environnement_requete("agent-dev.dracodev.net:8790") == "-dev"
assert suffixe_environnement_requete("Kanbante-Dev.Dracodev.Net") == "-dev"
# Hors motif : jamais de suffixe inventé depuis un hôte arbitraire.
assert suffixe_environnement_requete("192.168.30.101:8082") is None
assert suffixe_environnement_requete("127.0.0.1") is None
assert suffixe_environnement_requete("kanbante-dev.autre-domaine.net") is None
assert suffixe_environnement_requete("dev.dracodev.net") is None
assert suffixe_environnement_requete("testserver") is None
assert suffixe_environnement_requete("") is None
assert suffixe_environnement_requete(None) is None
def test_requete_hote_dev_sans_env_tout_en_dev(monkeypatch, fake_gitea):
# Aucune variable d'environnement : barre, panneau (source de
# l'iframe de la console) et CSP entièrement en -dev, cohérents.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"})
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
# Source de l'iframe du panneau : l'origine injectée au JS.
assert 'api: "https://agent-dev.dracodev.net"' in r.text
# La CSP de la même réponse énonce la même origine résolue.
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent-dev.dracodev.net"
)
# Aucun nom prod ne subsiste dans le rendu.
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" not in r.text
assert 'api: "https://agent.dracodev.net"' not in r.text
def test_requete_hote_prod_sans_env_noms_officiels(monkeypatch, fake_gitea):
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante.dracodev.net"})
assert r.status_code == 200
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" in r.text
assert 'api: "https://agent.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.dracodev.net"
)
def test_requete_hote_lan_repli_configuration(monkeypatch, fake_gitea):
# Hôte hors motif (LAN/IP) : repli sur la configuration, ici
# sans variable — donc les défauts officiels, jamais devinés.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "192.168.30.101:8082"})
assert r.status_code == 200
for url in _DEFAUTS_OFFICIELS:
assert f"{url}/" in r.text
assert 'api: "https://agent.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.dracodev.net"
)
def test_requete_hote_lan_repli_derivation_configuration(
monkeypatch, fake_gitea
):
# Hôte hors motif mais configuration en -dev (journal seul) :
# le repli est la dérivation de configuration, panneau inclus.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements",
)
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "192.168.30.101:8082"})
assert r.status_code == 200
for url in _URLS_DEV:
assert f"{url}/" in r.text
assert 'api: "https://agent-dev.dracodev.net"' in r.text
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent-dev.dracodev.net"
)
def test_requete_explicite_gagne_sur_hote(monkeypatch, fake_gitea):
# L'explicite reste prioritaire, URL par URL : Flowdeck et
# l'agent explicites gagnent sur l'hôte dev ; les autres
# sœurs suivent l'hôte.
from fastapi.testclient import TestClient
import app.main as main
_nettoyer_env_resolution(monkeypatch)
monkeypatch.setenv("DRACO_FLOWDECK_URL", "https://flowdeck.example.test")
monkeypatch.setenv("AGENT_URL", "https://agent.example.test")
with TestClient(main.app) as client:
r = client.get("/", headers={"Host": "kanbante-dev.dracodev.net"})
assert r.status_code == 200
assert "https://flowdeck.example.test/" in r.text
assert "https://flowdeck-dev.dracodev.net/" not in r.text
assert 'api: "https://agent.example.test"' in r.text
# La CSP consomme l'origine explicite, comme l'iframe.
assert r.headers["content-security-policy"] == (
"frame-src 'self' https://agent.example.test"
)
for url in (
"https://og-dev.dracodev.net/",
"https://tiktak-dev.dracodev.net/",
"https://kanbante-dev.dracodev.net/",
):
assert url in r.text
def test_resolution_sans_requete_inchangee(monkeypatch):
# Contextes sans requête : la résolution par configuration
# seule, comme avant (défauts officiels sans environnement).
from app.config import _env_settings, resoudre_urls_draco
_nettoyer_env_resolution(monkeypatch)
urls = resoudre_urls_draco()
assert urls.sso_url == "https://og.dracodev.net"
assert urls.flowdeck_url == "https://flowdeck.dracodev.net"
assert urls.tiktak_url == "https://tiktak.dracodev.net"
assert urls.public_url == "https://kanbante.dracodev.net"
assert urls.agent_url == "https://agent.dracodev.net"
assert _env_settings().agent_url == "https://agent.dracodev.net"
+13
View File
@@ -97,7 +97,20 @@ def test_pont_inerte_sans_secret(monkeypatch, fake_gitea):
def test_bouton_sso_sur_la_page_login(client_sso):
# Défauts = noms officiels (DRACO_SSO_URL / KANBANTE_PUBLIC_URL).
r = client_sso.get("/login")
assert r.status_code == 200
assert "og.dracodev.net/sso/authorize" in r.text
assert "kanbante.dracodev.net%2Fauth%2Fdraco" in r.text
def test_bouton_sso_par_configuration(client_sso, monkeypatch):
# Dev : l'émettrice et le return_to suivent l'environnement.
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv(
"KANBANTE_PUBLIC_URL", "https://kanbante-dev.dracodev.net"
)
r = client_sso.get("/login")
assert r.status_code == 200
assert "og-dev.dracodev.net/sso/authorize" in r.text
assert "kanbante-dev.dracodev.net%2Fauth%2Fdraco" in r.text
+132 -5
View File
@@ -1,9 +1,11 @@
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
move_issue (0.36.0).
Le service d'agents doit pouvoir proposer des cartes et commenter
via Kanbante, sans passer par l'interface web. Les deux outils
réutilisent les services existants (création via _do_capture →
client de forge, commentaire via client.create_comment) et sont
Le service d'agents doit pouvoir proposer des cartes, commenter
et déplacer des cartes via Kanbante, sans passer par l'interface
web. Les trois outils réutilisent les services existants
(création via _do_capture → client de forge, commentaire via
client.create_comment, déplacement via _do_move_card) et sont
gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
"""
@@ -145,6 +147,131 @@ def test_mcp_write_tools_are_listed(client):
)
# ── Déplacement de carte (0.36.0) ──────────────────────────
def _seed_carte_todo(fake_gitea, number=1):
"""Place la carte dans « À faire », étiquette kanban:todo
miroir posée (la carte garde son étiquette « bug »)."""
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
issue = next(
i for i in fake_gitea.issues["Projets/demo"]
if i["number"] == number
)
issue["labels"].append(etiquette)
models.move_issue("Projets/demo", number, "todo")
return issue
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
issue = _seed_carte_todo(fake_gitea)
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["from_column"] == "todo"
assert payload["column"] == "review"
assert payload["label_sync"] == "ok"
# Étiquette kanban remplacée, les autres préservées.
noms = [l["name"] for l in issue["labels"]]
assert "kanban:review" in noms
assert "kanban:todo" not in noms
assert "bug" in noms
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
# Le board suit le déplacement (lecture MCP relue).
board = json.loads(_result_text(_call(
client, "get_board", {"owner": "Projets", "repo": "demo"}
))[1])
assert any(c["number"] == 1 for c in board["review"])
assert not any(c["number"] == 1 for c in board["todo"])
# Journal : déplacement tracé avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
moved = [e for e in events if e["event_type"] == "moved"]
assert moved and moved[0]["source"] == "mcp"
assert moved[0]["from_column"] == "todo"
assert moved[0]["to_column"] == "review"
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle, cle in (
("En cours", "in_progress"),
("Révision", "review"),
("kanban:qa", "qa"),
("À faire", "todo"),
):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is False, (libelle, text)
assert json.loads(text)["column"] == cle
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle in ("done", "Terminé"):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Terminé" in text
# Aucune écriture : ni étiquette, ni état, ni journal.
assert fake_gitea.label_ops == []
assert fake_gitea.state_changes == []
events = models.get_events(repo_full_name="Projets/demo", limit=10)
assert not [e for e in events if e["event_type"] == "moved"]
def test_mcp_move_issue_errors(client, fake_gitea):
# Colonne inconnue : erreur propre, avec les colonnes valides.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "nimporte",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Colonnes valides" in text and "review" in text
# Issue inconnue, puis dépôt inconnu.
for arguments in (
{"owner": "Projets", "repo": "demo", "number": 999,
"column": "review"},
{"owner": "Projets", "repo": "inexistant", "number": 1,
"column": "review"},
):
result, text = _result_text(_call(client, "move_issue", arguments))
assert result["isError"] is True
assert "inconnu" in text
# Une pull request n'est pas une carte.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 2,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "pull request" in text
assert fake_gitea.label_ops == []
def test_mcp_move_issue_is_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["move_issue"]["inputSchema"]["required"] == [
"owner", "repo", "number", "column",
]
info = client.get("/mcp").json()
assert "move_issue" in info["write_tools"]
# ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture()
+6 -2
View File
@@ -97,11 +97,15 @@ def test_metrics_counts_and_cycle_time():
def test_column_entry_times_ignores_comments():
# Capturer l'horodatage UNE fois : _days_ago() réévalué à
# l'assertion basculait d'une seconde une fois sur ~N
# (échec CI 2333 du 2026-10-26, run jumeau vert).
entry = _days_ago(3)
models.log_event("a/b", 1, "moved", to_column="todo",
created_at=_days_ago(3))
created_at=entry)
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
times = models.column_entry_times("a/b")
assert times[1] == _days_ago(3)
assert times[1] == entry
# ── Panneau détail ─────────────────────────────────────────
+277
View File
@@ -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
+136
View File
@@ -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"
+39
View File
@@ -0,0 +1,39 @@
"""Palier 22 (0.33.0): barre de filtres de la page Mes issues —
facettes serveur, lignes annotées, moteur client chargé."""
def test_my_page_has_filter_bar_and_facets(client):
r = client.get("/my")
assert r.status_code == 200
# La barre et ses contrôles.
assert 'id="my-filters"' in r.text
assert 'id="my-search"' in r.text
assert 'id="my-repo"' in r.text
assert 'id="my-label"' in r.text
assert 'id="my-sort"' in r.text
assert 'id="my-col-chips"' in r.text
assert 'id="my-count"' in r.text
assert 'id="my-reset"' in r.text
# Facettes avec compteurs (démo : 1 issue assignée,
# étiquette « bug »).
assert "Projets/demo (1)" in r.text
assert "bug (1)" in r.text
# Le moteur de filtrage est chargé, versionné.
assert "/static/js/my.js?v=" in r.text
def test_my_rows_carry_filter_attributes(client):
r = client.get("/my")
# Attributs data-* consommés par le moteur client.
assert 'data-column="backlog"' in r.text
assert 'data-search="#1 Tâche un Projets/demo bug"' in r.text
assert 'data-updated="2026-09-02T09:00:00Z"' in r.text
assert 'data-repo="Projets/demo"' in r.text
# Chips de colonnes présentes, avec compteurs.
assert 'data-col="backlog"' in r.text
assert 'data-col=""' in r.text # « Toutes les colonnes »
def test_my_empty_state_present_but_hidden(client):
r = client.get("/my")
assert 'id="my-empty" hidden' in r.text
+72
View File
@@ -0,0 +1,72 @@
"""Palier 23 (0.34.0, issue #24): vue « En vol partout » —
l'encours tous projets, regroupé par colonne, plus vieilles
cartes d'abord."""
from datetime import datetime, timedelta, timezone
from app import models
def _days_ago(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
def test_inflight_groups_by_column_with_project(client):
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/inflight")
assert r.status_code == 200
assert "2 cartes en vol" in r.text
assert "Tâche focus" in r.text
assert "Tâche un" in r.text
# Le projet est indiqué sur la carte.
assert "Projets/demo" in r.text
# Groupes par colonne, pas par projet.
assert "🚧 En cours" in r.text
assert "🔍 Révision" in r.text
# L'âge dans la colonne est affiché.
assert "dans la colonne" in r.text
def test_inflight_excludes_backlog_and_todo(client):
models.move_issue("Projets/demo", 5, "todo")
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
assert "Rien en vol" in r.text
def test_inflight_oldest_first(client):
# Deux cartes en cours : entrée il y a 10 j et il y a 2 j.
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "in_progress")
models.log_event(
"Projets/demo", 5, "moved", to_column="in_progress",
created_at=_days_ago(10),
)
models.log_event(
"Projets/demo", 1, "moved", to_column="in_progress",
created_at=_days_ago(2),
)
r = client.get("/inflight")
assert r.status_code == 200
assert r.text.index("Tâche focus") < r.text.index("Tâche un")
def test_inflight_closed_issue_disappears(client, fake_gitea):
models.move_issue("Projets/demo", 5, "in_progress")
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
issue["state"] = "closed"
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
def test_inflight_nav_and_page_chrome(client):
r = client.get("/inflight")
assert 'href="/inflight"' in r.text # menu latéral Pilotage
assert "En vol partout" in r.text
+77
View File
@@ -0,0 +1,77 @@
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
from tests.conftest import csrf_from
def test_milestone_create_via_api(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Version 2.0",
"description": "Le gros morceau",
"due_on": "2027-01-31",
"csrf_token": token,
},
)
assert r.status_code == 201
payload = r.json()
assert payload["ok"] is True
assert payload["milestone"]["title"] == "Version 2.0"
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
titles = [
m["title"] for m in fake_gitea._milestones["Projets/demo"]
]
assert "Version 2.0" in titles
def test_milestone_create_rejects_bad_date(client, fake_gitea):
token = csrf_from(client)
avant = len(fake_gitea._milestones["Projets/demo"])
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Jalon invalide",
"due_on": "31/01/2027",
"csrf_token": token,
},
)
assert r.status_code == 400
# Rien d'écrit côté forge.
assert len(fake_gitea._milestones["Projets/demo"]) == avant
def test_milestone_update_title_and_clear_due(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/update",
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
)
assert r.status_code == 200
sprint = fake_gitea._milestones["Projets/demo"][0]
assert sprint["title"] == "Sprint 1 — prolongé"
assert sprint["due_on"] is None # échéance retirée
def test_milestone_close(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/close",
data={"csrf_token": token},
)
assert r.status_code == 200
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
page = client.get("/project/Projets/demo")
assert "Version" in page.text # la page répond toujours
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
def test_project_page_has_milestone_management(client):
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "Nouveau jalon" in page.text
assert "Modifier / fermer" in page.text
assert 'name="due_on"' in page.text
assert "/static/js/project.js?v=" in page.text
+36
View File
@@ -0,0 +1,36 @@
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
les projets (section Épinglés incluse) s'ordonnent par le nombre
de cartes dans la colonne de validation."""
from app import models
def test_dashboard_sort_offers_review_mode(client):
r = client.get("/")
assert r.status_code == 200
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
def test_dashboard_cards_carry_review_count(client):
# Aucune carte en révision au départ.
r = client.get("/")
assert 'data-review="0"' in r.text
# Une carte passe en Révision : le compteur de SA carte monte.
models.move_issue("Projets/demo", 5, "review")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/")
assert 'data-review="2"' in r.text
def test_dashboard_js_knows_review_mode(client):
# Le moteur de tri partagé est servi tel quel : le mode
# 'review' doit y figurer (tri décroissant sur data-review).
r = client.get("/static/js/dashboard.js")
assert r.status_code == 200
assert "mode === 'review'" in r.text
assert "data-review" in r.text
# Le sens : les modes numeriques trient en ka - kb (cles
# niees = plus grand compteur d\'abord). Sans cette
# branche, le comparateur generique inversait le tri :
# bug attrape en navigateur reel sur le tri historique.
assert "return ka - kb" in r.text
+5 -2
View File
@@ -121,7 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [
"list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment",
"create_issue", "add_comment", "move_issue",
]
@@ -167,8 +167,11 @@ def test_mcp_info_endpoint(client):
assert r.status_code == 200
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement.
# 0.36.0 : move_issue s'ajoute aux écritures déclarées.
assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
assert r.json()["write_tools"] == [
"create_issue", "add_comment", "move_issue",
]
# ── Board : gestion des étiquettes ────────────────────────