Compare commits

...
11 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
35 changed files with 2503 additions and 103 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=
+323
View File
@@ -1,5 +1,328 @@
# Changelog # 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 ## 0.31.0 — 2026-10-23
### Ajouté — description éditable, pièces jointes, clic fiable ### Ajouté — description éditable, pièces jointes, clic fiable
+6
View File
@@ -111,6 +111,12 @@ sudo systemctl enable --now docker
|---|---|---| |---|---|---|
| `GITEA_URL` | `https://git.dracodev.net` | Instance Gitea | | `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 | | `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_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_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 | | `KANBANTE_REQUIRE_AUTH` | `0` | `1` = l'app refuse de servir (503) si l'auth n'est pas configurée |
+254 -7
View File
@@ -96,10 +96,72 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie" which an open card is a "zombie"
on /health (issue #9). Default 30. 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 AGENT_EVENEMENTS_URL Journal endpoint of the agent
service (Lot 2, contract ACC-0.2) service (Lot 2, contract ACC-0.2)
receiving this app's signed events. 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 AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the events it emits to that journal (the
service knows it as the "kanbante" service knows it as the "kanbante"
@@ -108,12 +170,14 @@ Environment variables:
""" """
import os import os
import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.31.0" APP_VERSION = "0.37.1"
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
@@ -149,6 +213,144 @@ def _env_wip_limits(name: str) -> dict[str, int]:
return limits 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) @dataclass(frozen=True)
class Settings: class Settings:
gitea_url: str = "https://git.dracodev.net" gitea_url: str = "https://git.dracodev.net"
@@ -188,6 +390,10 @@ class Settings:
draco_secret_prev: str = "" draco_secret_prev: str = ""
sso_machine_key: str = "" sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net" 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 archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements" agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = "" agent_evenements_cle: str = ""
@@ -205,6 +411,29 @@ def get_settings() -> Settings:
def _env_settings() -> Settings: def _env_settings() -> Settings:
"""Build settings from the current environment only.""" """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( return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""), gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -247,12 +476,30 @@ def _env_settings() -> Settings:
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""), draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""), draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), 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), archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=os.getenv( agent_evenements_url=agent_evenements_url,
"AGENT_EVENEMENTS_URL",
"https://agent.dracodev.net/api/v1/evenements",
).rstrip("/"),
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""), agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
) )
+3 -1
View File
@@ -17,6 +17,8 @@ import logging
import httpx import httpx
from app.config import APP_VERSION
logger = logging.getLogger("kanbante.digest") logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05" 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, "protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.31.0"}, "clientInfo": {"name": "kanbante", "version": APP_VERSION},
}, },
req_id=1, req_id=1,
) )
+53
View File
@@ -477,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]: async def list_starred(self) -> list[dict]:
"""Repos starred by the token user (issue #20: pinning rides """Repos starred by the token user (issue #20: pinning rides
on Gitea stars — the forge stays the source of truth).""" on Gitea stars — the forge stays the source of truth)."""
+489 -45
View File
@@ -23,6 +23,7 @@ import logging
import re import re
import secrets import secrets
import time import time
import unicodedata
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -41,7 +42,7 @@ from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape from jinja2 import Environment, FileSystemLoader, select_autoescape
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from app.config import APP_VERSION, get_settings from app.config import APP_VERSION, get_settings, resoudre_urls_draco
from app.gitea_client import GiteaClient from app.gitea_client import GiteaClient
from app import code_links as code_links_service from app import code_links as code_links_service
from app import digest as digest_service from app import digest as digest_service
@@ -2118,6 +2119,7 @@ _NAV_BY_TEMPLATE = {
"capture.html": "capture", "capture.html": "capture",
"digest.html": "digest", "digest.html": "digest",
"forecast.html": "forecast", "forecast.html": "forecast",
"inflight.html": "inflight",
"rules.html": "rules", "rules.html": "rules",
"health.html": "health", "health.html": "health",
"health_project.html": "health", "health_project.html": "health",
@@ -2145,6 +2147,31 @@ def render(
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
context.setdefault("csrf_token", token) context.setdefault("csrf_token", token)
context.setdefault("version", APP_VERSION) context.setdefault("version", APP_VERSION)
# Noms publics dracodev par configuration (dev/prod, 0.32.0) :
# aucun nom public n'est codé en dur dans les gabarits ni dans le
# JS statique — tout vient de app.config (défauts = noms
# officiels/prod ; la dev déclare ses noms en ``-dev`` par .env).
# Résolution par requête (0.32.2) : les URL rendues suivent
# l'hôte public de la requête quand aucune variable explicite
# ne les fixe (voir resoudre_urls_draco) — barre, panneau et
# CSP de cette même réponse consomment la même résolution.
_pub = get_settings()
_urls = resoudre_urls_draco(request.headers.get("host"), base=_pub)
context.setdefault(
"draco_urls",
{
"obsigate": _urls.sso_url,
"flowdeck": _urls.flowdeck_url,
"tiktak": _urls.tiktak_url,
"kanbante": _urls.public_url,
},
)
context.setdefault("agent_url", _urls.agent_url)
context.setdefault(
"sso_authorize_url",
f"{_urls.sso_url}/sso/authorize?return_to="
f"{quote(_urls.public_url + '/auth/draco', safe='')}",
)
# Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune. # Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune.
context.setdefault( context.setdefault(
"draco_avatar", (get_settings().auth_user or "K")[:1].upper() "draco_avatar", (get_settings().auth_user or "K")[:1].upper()
@@ -2174,6 +2201,15 @@ def render(
context.setdefault("user_workspaces", []) context.setdefault("user_workspaces", [])
html = _tpl_env.get_template(name).render(**context) html = _tpl_env.get_template(name).render(**context)
response = HTMLResponse(html, status_code=status_code) response = HTMLResponse(html, status_code=status_code)
# CSP du panneau (0.32.2) : la seule iframe de l'app est la
# console Spark, chargée depuis l'origine de l'agent résolue
# pour CETTE requête — la même que celle injectée au panneau
# (DRACO_PANNEAU_CONFIG), jamais une autre. Aucune CSP
# n'existait auparavant : l'origine de l'agent n'était énoncée
# nulle part.
response.headers["Content-Security-Policy"] = (
f"frame-src 'self' {_urls.agent_url}"
)
if generated: if generated:
response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True) response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True)
return response return response
@@ -3112,7 +3148,10 @@ async def project_page(request: Request, owner: str, repo: str):
est = forecast_service.estimate(opened, weekly) est = forecast_service.estimate(opened, weekly)
milestones.append( milestones.append(
{ {
"id": m.get("id"),
"title": m.get("title", ""), "title": m.get("title", ""),
"description": m.get("description") or "",
"due_iso": due_date.isoformat() if due_date else "",
"total": total, "total": total,
"closed": closed, "closed": closed,
"open": opened, "open": opened,
@@ -3996,6 +4035,88 @@ async def focus(request: Request):
) )
async def _collect_inflight(ws_repos, ws_id: int) -> dict:
"""The work in flight, grouped BY COLUMN (issue #24).
Reuses the Focus pipeline (per-project groups of cards in
the workspace's in-flight columns), then pivots: one group
per column, cards sorted by age in column, oldest first —
the oldest cards are the signal. Ages come from the same
_card_ages pipeline as the board.
"""
per_repo = await _collect_focus_groups(ws_repos, ws_id)
columns: dict[str, list[dict]] = {
key: [] for key in bcols.focus_keys(ws_id)
}
for group in per_repo:
full_name = group["full_name"]
cid = group.get("conn_id", registry.default_id)
open_issues = [c["issue"] for c in group["cards"]]
board = get_board_state(
full_name, workspace_id=ws_id, connection_id=cid
)
ages = _card_ages(
full_name, open_issues, board,
workspace_id=ws_id, connection_id=cid,
)
for card in group["cards"]:
age = ages.get(card["issue"]["number"]) or {}
columns.setdefault(card["column"], []).append(
{
"issue": card["issue"],
"repo": full_name,
"column": card["column"],
"age_days": age.get("days", 0),
"age_since": age.get("since", ""),
}
)
groups = []
total = 0
for key in bcols.keys(ws_id):
cards = columns.get(key)
if not cards:
continue
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
groups.append({"column": key, "cards": cards})
total += len(cards)
for key, cards in columns.items():
if key not in {g["column"] for g in groups} and cards:
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
groups.append({"column": key, "cards": cards})
total += len(cards)
# Limites WIP globales lisibles : somme, par colonne, des
# limites définies dans les projets (0.33.0 : lecture seule).
settings = get_settings()
wip_globals: dict[str, int] = {}
for _repo, limits in (settings.wip_limits or {}).items():
for col, limit in (limits or {}).items():
try:
wip_globals[col] = wip_globals.get(col, 0) + int(limit)
except (TypeError, ValueError):
continue
wip_globals = {c: v for c, v in wip_globals.items() if v > 0}
return {"groups": groups, "total": total, "wip_globals": wip_globals}
@app.get("/inflight", response_class=HTMLResponse)
async def inflight(request: Request):
"""Everything in flight, all projects, grouped by column (#24)."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
ws_repos, _ws = await _workspace_repos(request)
data = await _collect_inflight(ws_repos, ws_id)
return render(
request,
"inflight.html",
groups=data["groups"],
total=data["total"],
wip_globals=data["wip_globals"],
column_labels=bcols.names(ws_id),
column_colors=bcols.colors(ws_id),
aging_days=get_settings().aging_days,
)
def _group_issue_results(issues: list[dict]) -> list[dict]: def _group_issue_results(issues: list[dict]) -> list[dict]:
"""Group search results by repo, annotated with the board column.""" """Group search results by repo, annotated with the board column."""
by_repo: dict[str, list[dict]] = {} by_repo: dict[str, list[dict]] = {}
@@ -4211,17 +4332,181 @@ async def my_issues(request: Request):
logger.debug("search failed for /my (conn %s): %s", cid, exc) logger.debug("search failed for /my (conn %s): %s", cid, exc)
total = len(results) total = len(results)
groups = _group_issue_results(results) groups = _group_issue_results(results)
# Facettes de filtrage (0.33.0) : compteurs calculés une
# fois côté serveur ; le filtrage lui-même est instantané
# côté client, sur des attributs data-* des lignes.
names = _ws_names(request)
order = _ws_keys(request)
repo_facets = [
{"full_name": g["full_name"], "count": len(g["rows"])}
for g in groups
]
col_counts: dict[str, int] = {}
label_counts: dict[str, int] = {}
for g in groups:
for row in g["rows"]:
col_counts[row["column"]] = col_counts.get(row["column"], 0) + 1
for label in row["issue"].get("labels") or []:
name = label.get("name") or ""
if name:
label_counts[name] = label_counts.get(name, 0) + 1
column_facets = [
{"key": key, "count": col_counts[key]}
for key in order
if key in col_counts
] + [
{"key": key, "count": count}
for key, count in col_counts.items()
if key not in order
]
label_facets = [
{"name": name, "count": label_counts[name]}
for name in sorted(label_counts, key=str.lower)
]
return render( return render(
request, request,
"my.html", "my.html",
user=user, user=user,
groups=groups, groups=groups,
total=total, total=total,
column_labels=_ws_names(request), column_labels=names,
column_colors=_ws_colors(request), column_colors=_ws_colors(request),
repo_facets=repo_facets,
column_facets=column_facets,
label_facets=label_facets,
) )
def _milestone_due_iso(due: str) -> tuple[str | None, bool]:
"""Validate a YYYY-MM-DD form date -> (ISO instant, ok)."""
due = (due or "").strip()
if not due:
return None, True
try:
parsed = datetime.strptime(due, "%Y-%m-%d")
except ValueError:
return None, False
# Midi UTC, pas minuit : Gitea renvoie l'échéance dans le
# fuseau de son instance (Toronto = UTC-5) — à minuit UTC,
# la date affichée reculait d'un jour (attrapé en réel).
return parsed.strftime("%Y-%m-%dT12:00:00Z"), True
@app.post("/api/repos/{owner}/{repo}/milestones")
async def api_milestone_create(
request: Request,
owner: str,
repo: str,
title: str = Form(""),
description: str = Form(""),
due_on: str = Form(""),
csrf_token: str = Form(""),
):
"""Create a milestone on the forge (issue #26)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
title = title.strip()
if not title or len(title) > 255:
return JSONResponse(
{"ok": False, "error": "Titre requis (255 caractères max)"},
status_code=400,
)
due_iso, ok = _milestone_due_iso(due_on)
if not ok:
return JSONResponse(
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
status_code=400,
)
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
milestone = await client.create_milestone(
owner, repo, title, description.strip(), due_iso
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return JSONResponse(
{"ok": True, "milestone": {
"id": milestone.get("id"),
"title": milestone.get("title"),
"due_on": milestone.get("due_on"),
}},
status_code=201,
)
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/update")
async def api_milestone_update(
request: Request,
owner: str,
repo: str,
milestone_id: int,
title: str = Form(""),
due_on: str = Form(""),
csrf_token: str = Form(""),
):
"""Edit a milestone's title / due date (issue #26).
An empty due date CLEARS the milestone's deadline.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
title = title.strip()
if not title or len(title) > 255:
return JSONResponse(
{"ok": False, "error": "Titre requis (255 caractères max)"},
status_code=400,
)
due_iso, ok = _milestone_due_iso(due_on)
if not ok:
return JSONResponse(
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
status_code=400,
)
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
milestone = await client.update_milestone(
owner, repo, milestone_id, title=title,
due_on=due_iso, clear_due=due_iso is None,
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return {"ok": True, "milestone": {
"id": milestone.get("id"),
"title": milestone.get("title"),
"due_on": milestone.get("due_on"),
}}
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/close")
async def api_milestone_close(
request: Request,
owner: str,
repo: str,
milestone_id: int,
csrf_token: str = Form(""),
):
"""Close a milestone on the forge (issue #26)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
await client.update_milestone(
owner, repo, milestone_id, state="closed"
)
except httpx.HTTPStatusError as exc:
return JSONResponse(
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
status_code=502,
)
return {"ok": True}
# ── Briefing ─────────────────────────────────────────────── # ── Briefing ───────────────────────────────────────────────
async def _briefing_data() -> dict: async def _briefing_data() -> dict:
@@ -5118,38 +5403,34 @@ async def activity(request: Request):
# ── Routes: API ──────────────────────────────────────────── # ── Routes: API ────────────────────────────────────────────
@app.post("/api/move") async def _do_move_card(
async def api_move( repo_full_name: str,
request: Request, issue_number: int,
repo_full_name: str = Form(...), to_column: str,
issue_number: int = Form(...), *,
to_column: str = Form(...), from_column: str = "",
from_column: str = Form(""), to_order: str = "",
to_order: str = Form(""), from_order: str = "",
from_order: str = Form(""), client,
csrf_token: str = Form(""), conn_id: int,
): ws_id: int,
"""Move an issue to a new column and persist the resulting order. source: str = "ui",
) -> dict:
"""Déplacement effectif d'une carte, une fois la portée résolue.
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes Board local (placement + ordres), journal des événements,
the issue in Gitea and moving out of "done" reopens it. A failed write-back Gitea (fermeture en entrant dans « done »,
write-back is reported in the response (gitea_sync="failed") instead réouverture en en sortant — échecs rapportés, jamais
of failing the move itself. bloquants) et miroir de l'étiquette kanban:* de la colonne
(les autres étiquettes de l'issue sont préservées).
Partagé par POST /api/move (interface, source « ui ») et par
l'outil MCP move_issue (source « mcp ») — même patron que
_do_capture pour la création. L'outil MCP refuse la colonne
« done » en amont : y entrer ferme l'issue, et Terminé
appartient à Bruno dans son interface.
""" """
if not _csrf_valid(request, csrf_token): owner, repo = _split_full_name(repo_full_name)
return _csrf_error()
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
if to_column not in bcols.keys(ws_id):
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
move_issue( move_issue(
repo_full_name, issue_number, to_column, repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id, workspace_id=ws_id, connection_id=conn_id,
@@ -5175,7 +5456,7 @@ async def api_move(
if from_column != to_column: if from_column != to_column:
log_event( log_event(
repo_full_name, issue_number, "moved", repo_full_name, issue_number, "moved",
from_column or None, to_column, from_column or None, to_column, source=source,
workspace_id=ws_id, connection_id=conn_id, workspace_id=ws_id, connection_id=conn_id,
) )
# Émission Lot 2 (journal du service d'agents) : le titre # Émission Lot 2 (journal du service d'agents) : le titre
@@ -5189,7 +5470,7 @@ async def api_move(
titre_carte = "" titre_carte = ""
_emettre_changement_colonne( _emettre_changement_colonne(
repo_full_name, issue_number, from_column or None, to_column, repo_full_name, issue_number, from_column or None, to_column,
"ui", title=titre_carte, source, title=titre_carte,
) )
result: dict = {"ok": True, "gitea_sync": "skipped"} result: dict = {"ok": True, "gitea_sync": "skipped"}
@@ -5201,14 +5482,14 @@ async def api_move(
result["gitea_sync"] = "closed" result["gitea_sync"] = "closed"
log_event( log_event(
repo_full_name, issue_number, "closed", to_column="done", repo_full_name, issue_number, "closed", to_column="done",
workspace_id=ws_id, connection_id=conn_id, source=source, workspace_id=ws_id, connection_id=conn_id,
) )
elif from_column == "done": elif from_column == "done":
await client.set_issue_state(owner, repo, issue_number, "open") await client.set_issue_state(owner, repo, issue_number, "open")
result["gitea_sync"] = "reopened" result["gitea_sync"] = "reopened"
log_event( log_event(
repo_full_name, issue_number, "reopened", repo_full_name, issue_number, "reopened",
from_column="done", to_column=to_column, from_column="done", to_column=to_column, source=source,
workspace_id=ws_id, connection_id=conn_id, workspace_id=ws_id, connection_id=conn_id,
) )
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
@@ -5254,6 +5535,46 @@ async def api_move(
return result return result
@app.post("/api/move")
async def api_move(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
to_column: str = Form(...),
from_column: str = Form(""),
to_order: str = Form(""),
from_order: str = Form(""),
csrf_token: str = Form(""),
):
"""Move an issue to a new column and persist the resulting order.
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
the issue in Gitea and moving out of "done" reopens it. A failed
write-back is reported in the response (gitea_sync="failed") instead
of failing the move itself.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
if to_column not in bcols.keys(ws_id):
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
return await _do_move_card(
repo_full_name, issue_number, to_column,
from_column=from_column, to_order=to_order,
from_order=from_order, client=client, conn_id=conn_id,
ws_id=ws_id, source="ui",
)
@app.post("/api/issues") @app.post("/api/issues")
async def api_create_issue( async def api_create_issue(
request: Request, request: Request,
@@ -5952,9 +6273,10 @@ async def api_toggle_issue_label(
# ── MCP server (tools over JSON-RPC) ─────────────────────── # ── MCP server (tools over JSON-RPC) ───────────────────────
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue, # Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
# add_comment) depuis la 0.26.0, gardées par la même authentification # add_comment) depuis la 0.26.0, move_issue depuis la 0.36.0,
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le # gardées par la même authentification que tout /mcp (session,
# middleware : un jeton n'ouvre jamais l'interface web). # Basic ou jeton d'API kbt_ — voir le middleware : un jeton
# n'ouvre jamais l'interface web).
MCP_TOOLS = [ MCP_TOOLS = [
{ {
@@ -6053,15 +6375,77 @@ MCP_TOOLS = [
"required": ["owner", "repo", "number", "body"], "required": ["owner", "repo", "number", "body"],
}, },
}, },
{
"name": "move_issue",
"description": "Move a card (issue) to another board column. "
"column accepts the column key (backlog, todo, in_progress, "
"qa, review), the forge label (kanban:review) or the French "
"display label (« À faire », « En cours », « Révision »…). "
"The kanban:* label on the issue is replaced; its other "
"labels are preserved. Moving to done (« Terminé ») is "
"REFUSED: that column belongs to the human owner — agents "
"stop at review. Write tool — same /mcp authentication as "
"the reads (kbt_ API token, session or Basic).",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
"column": {"type": "string"},
},
"required": ["owner", "repo", "number", "column"],
},
},
] ]
def _plier_libelle_colonne(valeur: str) -> str:
"""Plie un libellé de colonne pour comparaison tolérante :
minuscules, accents retirés, emoji et ponctuation éliminés,
séparateurs unifiés en espaces (« in_progress »,
« in-progress » et « In progress » se rejoignent)."""
texte = unicodedata.normalize("NFKD", valeur or "")
texte = "".join(c for c in texte if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]+", " ", texte.lower()).strip()
def _normaliser_colonne_mcp(brut: str, ws_id: int) -> str:
"""Résout la colonne visée par l'outil MCP move_issue.
Accepte la clé (« review »), l'étiquette de forge
(« kanban:review », alias d'espace compris) et le libellé
français affiché (« Révision », « À faire »…) — les noms
d'affichage viennent de la définition des colonnes de
l'espace, pas d'une table figée. Lève ValueError, avec la
liste des colonnes valides, si rien ne correspond.
"""
candidats: dict[str, str] = {}
for colonne in bcols.get_columns(ws_id):
cle = colonne["key"]
candidats[_plier_libelle_colonne(cle)] = cle
candidats[_plier_libelle_colonne(colonne.get("name") or "")] = cle
if colonne.get("label"):
candidats[_plier_libelle_colonne(colonne["label"])] = cle
for etiquette, cle in bcols.label_lookup(ws_id).items():
candidats[_plier_libelle_colonne(etiquette)] = cle
candidats["to do"] = "todo"
visee = _plier_libelle_colonne(brut)
if visee in candidats:
return candidats[visee]
valides = ", ".join(bcols.keys(ws_id))
raise ValueError(
f"Colonne inconnue : « {brut} ». Colonnes valides : {valides}."
)
async def _mcp_call_tool(name: str, args: dict) -> str: async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one MCP tool and return text content. """Execute one MCP tool and return text content.
Reads hit the forge's read API; the two write tools Reads hit the forge's read API; the write tools reuse the
(create_issue, add_comment) reuse the existing creation / existing services (creation via _do_capture, comment via the
comment services — never a reimplemented forge call. forge client, move via _do_move_card) — never a reimplemented
forge call.
""" """
if name == "list_projects": if name == "list_projects":
repos = await gitea.search_repos() repos = await gitea.search_repos()
@@ -6182,6 +6566,65 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
}, },
ensure_ascii=False, ensure_ascii=False,
) )
if name == "move_issue":
owner, repo = args["owner"], args["repo"]
number = int(args["number"])
full_name = f"{owner}/{repo}"
client, conn_id = await _client_for_repo(full_name)
ws_id = next(
(
ws["id"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
),
1,
)
target = _normaliser_colonne_mcp(str(args.get("column") or ""), ws_id)
if target == "done":
# Garde doctrine : Terminé appartient à Bruno dans son
# interface — refus avant toute écriture (entrer dans
# « done » fermerait l'issue côté forge).
raise ValueError(
"Colonne refusée : « Terminé » (done) appartient à "
"Bruno — les agents s'arrêtent en Révision (review)."
)
try:
issue = await client.get_issue(owner, repo, number)
except httpx.HTTPStatusError as exc:
if exc.response is not None and exc.response.status_code == 404:
raise ValueError(
f"Dépôt ou issue inconnu : {full_name}#{number}"
) from exc
raise
if issue.get("pull_request"):
raise ValueError(
f"{full_name}#{number} est une pull request, pas une carte."
)
placement: dict[int, str] = {}
for col_name, cards in get_board_state(full_name).items():
for card in cards:
placement[card["issue_number"]] = col_name
from_column = placement.get(number)
if from_column is None:
from_column = (
"done" if issue.get("state") == "closed" else "backlog"
)
result = await _do_move_card(
full_name, number, target,
from_column=from_column,
client=client, conn_id=conn_id, ws_id=ws_id, source="mcp",
)
return json.dumps(
{
"repo_full_name": full_name,
"number": number,
"title": issue.get("title", ""),
"from_column": from_column,
"column": target,
**result,
},
ensure_ascii=False,
)
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
@@ -6193,7 +6636,7 @@ async def mcp_info():
"version": APP_VERSION, "version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)", "transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "read_only": False,
"write_tools": ["create_issue", "add_comment"], "write_tools": ["create_issue", "add_comment", "move_issue"],
"tools": [t["name"] for t in MCP_TOOLS], "tools": [t["name"] for t in MCP_TOOLS],
} }
@@ -6202,8 +6645,9 @@ async def mcp_info():
async def mcp_endpoint(request: Request): async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping. """Minimal MCP server: initialize / tools/list / tools/call / ping.
Reads since 0.5.0; since 0.26.0, two minimal write tools Reads since 0.5.0; since 0.26.0, minimal write tools
(create_issue, add_comment) for the agent service, guarded by (create_issue, add_comment — move_issue since 0.36.0) for the
agent service, guarded by
the same /mcp authentication as the reads (session, Basic or the same /mcp authentication as the reads (session, Basic or
kbt_ API token — a token never unlocks the web UI). kbt_ API token — a token never unlocks the web UI).
""" """
+11 -7
View File
@@ -1,7 +1,10 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28). {# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4 Snippet vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution (ui/barre-apps/) : structure inchangée ; seule adaptation locale
passe par une nouvelle version de commons. (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. CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js.
Inclus par _header.html (toutes les pages applicatives ; ni le Inclus par _header.html (toutes les pages applicatives ; ni le
wallboard ni l'écran de connexion, volontaires). #} wallboard ni l'écran de connexion, volontaires). #}
@@ -15,20 +18,20 @@
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span> <span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a> </a>
<nav class="draco-apptabs" aria-label="Applications"> <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-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span> <span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a> </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-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span> <span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a> </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-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span> <span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span> <span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a> </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-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span> <span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span> <span class="draco-badge" data-badge="kanbante" hidden>0</span>
@@ -101,4 +104,5 @@
}; };
</script> </script>
<script defer src="/static/js/draco-barre.js?v={{ version }}"></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> <script defer src="/static/js/draco-panneau.js?v={{ version }}"></script>
+1
View File
@@ -20,6 +20,7 @@
("digest", "/digest", "🧠", "Digest"), ("digest", "/digest", "🧠", "Digest"),
]), ]),
("pilotage", "Pilotage", [ ("pilotage", "Pilotage", [
("inflight", "/inflight", "🛩️", "En vol"),
("forecast", "/forecast", "🔮", "Prévisions"), ("forecast", "/forecast", "🔮", "Prévisions"),
("health", "/health", "🩺", "Santé"), ("health", "/health", "🩺", "Santé"),
("rules", "/rules", "⚙️", "Règles"), ("rules", "/rules", "⚙️", "Règles"),
+18 -7
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title> <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/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script> <script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.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> </head>
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
@@ -54,13 +54,24 @@
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button> <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> <a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
{% 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 %} {% 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"> <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" <input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required> placeholder="Titre de l'issue..." required>
<button type="button" id="new-issue-desc-toggle" class="btn-sm" title="Ajouter une description">+ Description</button> <button type="submit" class="btn-save btn-inline">Créer l'issue</button>
<button type="submit" class="btn-save btn-inline">Créer</button> </div>
<textarea id="new-issue-body" rows="4" maxlength="100000" hidden <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> placeholder="Description (markdown)..."></textarea>
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
</details>
</form> </form>
</div> </div>
+2
View File
@@ -64,6 +64,7 @@
<label class="dash-tool">Tri <label class="dash-tool">Tri
<select id="dash-sort"> <select id="dash-sort">
<option value="default">Par défaut</option> <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="updated">Activité Gitea récente</option>
<option value="activity">Activité kanban récente</option> <option value="activity">Activité kanban récente</option>
<option value="open">Issues ouvertes ↓</option> <option value="open">Issues ouvertes ↓</option>
@@ -85,6 +86,7 @@
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}" data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}" data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}" 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-activity="{{ repo.activity.last_at if repo.activity else '' }}"
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}" data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())"> x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
+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> </label>
<button type="submit" class="login-submit">Se connecter</button> <button type="submit" class="login-submit">Se connecter</button>
</form> </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> <p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
</section> </section>
</main> </main>
+56 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mes issues — Kanbante</title> <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="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> </head>
<body> <body>
{% include "_header.html" %} {% 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> <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 %} {% else %}
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p> <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 %} {% if not groups %}
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div> <div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
{% endif %} {% endif %}
{% for group in groups %} {% 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> <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"> <div class="issue-results">
{% for row in group.rows %} {% for row in group.rows %}
{% set issue = row.issue %} {% 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> <span class="issue-state">🟢</span>
<a class="issue-main" href="/board/{{ group.full_name }}"> <a class="issue-main" href="/board/{{ group.full_name }}">
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }} <span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
@@ -46,6 +97,7 @@
<footer> <footer>
Kanbante v{{ version }} · mes issues Kanbante v{{ version }} · mes issues
</footer> </footer>
<script src="/static/js/my.js?v={{ version }}" defer></script>
<script src="/static/js/live.js" defer></script> <script src="/static/js/live.js" defer></script>
{% include "_palette.html" %} {% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script> <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"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title> <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="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> </head>
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
@@ -51,11 +53,39 @@
{% else %} {% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div> <div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %} {% 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> </div>
{% endfor %} {% endfor %}
{% else %} {% 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 %} {% 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>
<section class="proj-panel"> <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> <footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %} {% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script> <script src="/static/js/palette.js" defer></script>
</body> </body>
</html> </html>
+9
View File
@@ -6,6 +6,15 @@ services:
- "8080:8080" - "8080:8080"
environment: environment:
- GITEA_URL=${GITEA_URL:-https://git.dracodev.net} - 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} - GITEA_TOKEN=${GITEA_TOKEN}
- KANBANTE_DB_PATH=/data/kanbante.db - KANBANTE_DB_PATH=/data/kanbante.db
# Strongly recommended: without these, the app is unauthenticated. # Strongly recommended: without these, the app is unauthenticated.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.31.0" version = "0.37.1"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+57 -1
View File
@@ -1693,4 +1693,60 @@ body.sidebar-collapsed .ws-switch { display: none; }
.asset-row a { text-decoration: none; font-weight: 600; } .asset-row a { text-decoration: none; font-weight: 600; }
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; } .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; } .asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
#create-issue-form textarea { flex-basis: 100%; margin-top: 6px; }
/* ── 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; }
+5 -3
View File
@@ -122,9 +122,11 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
document.getElementById('new-issue-desc-toggle')?.addEventListener('click', () => { createForm?.addEventListener('keydown', (e) => {
const area = document.getElementById('new-issue-body'); if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
if (area) { area.hidden = !area.hidden; if (!area.hidden) area.focus(); } e.preventDefault();
createForm.requestSubmit();
}
}); });
// The "Glissez une carte ici" hint is rendered by the server only // The "Glissez une carte ici" hint is rendered by the server only
+8
View File
@@ -32,6 +32,7 @@
function cardKey(card, mode) { function cardKey(card, mode) {
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase(); if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10)); 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 === 'updated') return card.getAttribute('data-updated') || '';
if (mode === 'activity') return card.getAttribute('data-activity') || ''; if (mode === 'activity') return card.getAttribute('data-activity') || '';
return null; // 'default' : ordre serveur, on ne touche à rien return null; // 'default' : ordre serveur, on ne touche à rien
@@ -52,6 +53,13 @@
if (isNaN(tb)) return -1; if (isNaN(tb)) return -1;
return tb - ta; 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;
if (ka > kb) return mode === 'name' ? 1 : -1; if (ka > kb) return mode === 'name' ? 1 : -1;
return 0; return 0;
+47 -4
View File
@@ -19,7 +19,7 @@
* (persistée), thème clair/sombre aligné sur l'app hôte * (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct). * (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web * Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) DANS la page, en surimpression * 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 * pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion * s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle * courante, ?app= app d'origine déduite du sous-domaine) est celle
@@ -32,13 +32,18 @@
* configuré / exécuteur non configuré sont affichés tels quels. */ * configuré / exécuteur non configuré sont affichés tels quels. */
(function () { (function () {
"use strict"; "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 convId = null;
var convKind = null; // 'principale' | 'parallele' var convKind = null; // 'principale' | 'parallele'
var convTitre = ""; var convTitre = "";
var etatConnu = false; var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null; var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() { function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -251,7 +256,7 @@
' <span class="d-titre">Discussion principale</span>' + ' <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="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' + ' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' + ' <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>' +
'<div class="draco-discussions" hidden></div>' + '<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' + '<div class="draco-panneau-outils">' +
@@ -264,6 +269,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' + ' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' + ' <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-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>'; '</div>';
document.body.appendChild(root); document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs"); msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -272,6 +278,7 @@
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre"); titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions"); tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir); root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion); root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -326,8 +333,43 @@
titreEl.title = titreEl.textContent; 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() { function verifierIdentite() {
if (etatConnu) return; 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" }) fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) { .then(function (r) {
if (r.status === 401) { if (r.status === 401) {
@@ -346,7 +388,7 @@
}) })
.catch(function () { .catch(function () {
majEtat("err", "Service d'agents injoignable"); 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é.");
}); });
} }
@@ -802,6 +844,7 @@
root.classList.add("open"); root.classList.add("open");
appliquerTheme(); appliquerTheme();
verifierIdentite(); verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300); setTimeout(function () { inputEl.focus(); }, 300);
} }
function fermer() { if (root) root.classList.remove("open"); } 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 = [ var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' }, { icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' }, { 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: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' }, { icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' }, { 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);
}
});
});
})();
+51 -2
View File
@@ -359,14 +359,63 @@ class FakeGitea:
else: else:
self.starred.discard(f"{owner}/{repo}") self.starred.discard(f"{owner}/{repo}")
async def get_milestones(self, owner, repo, state="open"): @property
return [ def _milestones(self):
return self.__dict__.setdefault(
"milestones_store",
{
"Projets/demo": [
{ {
"id": 1, "title": "Sprint 1", "id": 1, "title": "Sprint 1",
"description": "",
"due_on": "2026-10-20T00:00:00Z", "due_on": "2026-10-20T00:00:00Z",
"state": "open",
"open_issues": 1, "closed_issues": 1, "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=""): async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1 self._next_number += 1
+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.css" in r.text
assert "draco-barre.js" in r.text assert "draco-barre.js" in r.text
assert 'window.DRACO_BAR_CONFIG' 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/", for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/",
"https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"): "https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"):
assert url in r.text 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): 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") r = client.get("/login")
assert r.status_code == 200 assert r.status_code == 200
assert "draco-bar" not in r.text 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): 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") r = client_sso.get("/login")
assert r.status_code == 200 assert r.status_code == 200
assert "og.dracodev.net/sso/authorize" in r.text assert "og.dracodev.net/sso/authorize" in r.text
assert "kanbante.dracodev.net%2Fauth%2Fdraco" 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 Le service d'agents doit pouvoir proposer des cartes, commenter
via Kanbante, sans passer par l'interface web. Les deux outils et déplacer des cartes via Kanbante, sans passer par l'interface
réutilisent les services existants (création via _do_capture → web. Les trois outils réutilisent les services existants
client de forge, commentaire via client.create_comment) et sont (création via _do_capture → client de forge, commentaire via
client.create_comment, déplacement via _do_move_card) et sont
gardés par la même authentification que les lectures /mcp : gardés par la même authentification que les lectures /mcp :
session, Basic ou jeton d'API kbt_ — jamais l'interface web. session, Basic ou jeton d'API kbt_ — jamais l'interface web.
""" """
@@ -145,6 +147,131 @@ def test_mcp_write_tools_are_listed(client):
) )
# ── Déplacement de carte (0.36.0) ──────────────────────────
def _seed_carte_todo(fake_gitea, number=1):
"""Place la carte dans « À faire », étiquette kanban:todo
miroir posée (la carte garde son étiquette « bug »)."""
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
issue = next(
i for i in fake_gitea.issues["Projets/demo"]
if i["number"] == number
)
issue["labels"].append(etiquette)
models.move_issue("Projets/demo", number, "todo")
return issue
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
issue = _seed_carte_todo(fake_gitea)
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is False
payload = json.loads(text)
assert payload["from_column"] == "todo"
assert payload["column"] == "review"
assert payload["label_sync"] == "ok"
# Étiquette kanban remplacée, les autres préservées.
noms = [l["name"] for l in issue["labels"]]
assert "kanban:review" in noms
assert "kanban:todo" not in noms
assert "bug" in noms
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
# Le board suit le déplacement (lecture MCP relue).
board = json.loads(_result_text(_call(
client, "get_board", {"owner": "Projets", "repo": "demo"}
))[1])
assert any(c["number"] == 1 for c in board["review"])
assert not any(c["number"] == 1 for c in board["todo"])
# Journal : déplacement tracé avec la source « mcp ».
events = models.get_events(repo_full_name="Projets/demo", limit=10)
moved = [e for e in events if e["event_type"] == "moved"]
assert moved and moved[0]["source"] == "mcp"
assert moved[0]["from_column"] == "todo"
assert moved[0]["to_column"] == "review"
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle, cle in (
("En cours", "in_progress"),
("Révision", "review"),
("kanban:qa", "qa"),
("À faire", "todo"),
):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is False, (libelle, text)
assert json.loads(text)["column"] == cle
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
_seed_carte_todo(fake_gitea)
for libelle in ("done", "Terminé"):
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": libelle,
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Terminé" in text
# Aucune écriture : ni étiquette, ni état, ni journal.
assert fake_gitea.label_ops == []
assert fake_gitea.state_changes == []
events = models.get_events(repo_full_name="Projets/demo", limit=10)
assert not [e for e in events if e["event_type"] == "moved"]
def test_mcp_move_issue_errors(client, fake_gitea):
# Colonne inconnue : erreur propre, avec les colonnes valides.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 1,
"column": "nimporte",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "Colonnes valides" in text and "review" in text
# Issue inconnue, puis dépôt inconnu.
for arguments in (
{"owner": "Projets", "repo": "demo", "number": 999,
"column": "review"},
{"owner": "Projets", "repo": "inexistant", "number": 1,
"column": "review"},
):
result, text = _result_text(_call(client, "move_issue", arguments))
assert result["isError"] is True
assert "inconnu" in text
# Une pull request n'est pas une carte.
r = _call(client, "move_issue", {
"owner": "Projets", "repo": "demo", "number": 2,
"column": "review",
})
result, text = _result_text(r)
assert result["isError"] is True
assert "pull request" in text
assert fake_gitea.label_ops == []
def test_mcp_move_issue_is_listed(client):
r = client.post("/mcp", json={
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
assert tools["move_issue"]["inputSchema"]["required"] == [
"owner", "repo", "number", "column",
]
info = client.get("/mcp").json()
assert "move_issue" in info["write_tools"]
# ── Garde par jeton kbt_ (auth active) ───────────────────── # ── Garde par jeton kbt_ (auth active) ─────────────────────
@pytest.fixture() @pytest.fixture()
+6 -2
View File
@@ -97,11 +97,15 @@ def test_metrics_counts_and_cycle_time():
def test_column_entry_times_ignores_comments(): 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", 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)) models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
times = models.column_entry_times("a/b") times = models.column_entry_times("a/b")
assert times[1] == _days_ago(3) assert times[1] == entry
# ── Panneau détail ───────────────────────────────────────── # ── Panneau détail ─────────────────────────────────────────
+4 -2
View File
@@ -95,9 +95,11 @@ def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
assert 'id="detail-edit-form"' in page.text assert 'id="detail-edit-form"' in page.text
assert 'id="detail-assets"' in page.text assert 'id="detail-assets"' in page.text
assert 'id="asset-form"' in page.text assert 'id="asset-form"' in page.text
# Création avec description dépliable. # 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-body"' in page.text
assert 'id="new-issue-desc-toggle"' 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(): async def test_gitea_download_follows_browser_download_url():
+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 == [ assert names == [
"list_projects", "get_board", "get_issue", "get_focus", "list_projects", "get_board", "get_issue", "get_focus",
"search_issues", "get_activity", "get_metrics", "search_issues", "get_activity", "get_metrics",
"create_issue", "add_comment", "create_issue", "add_comment", "move_issue",
] ]
@@ -167,8 +167,11 @@ def test_mcp_info_endpoint(client):
assert r.status_code == 200 assert r.status_code == 200
# 0.26.0 : écritures minimales ajoutées — le serveur n'est # 0.26.0 : écritures minimales ajoutées — le serveur n'est
# plus en lecture seule, et il le déclare honnêtement. # plus en lecture seule, et il le déclare honnêtement.
# 0.36.0 : move_issue s'ajoute aux écritures déclarées.
assert r.json()["read_only"] is False assert r.json()["read_only"] is False
assert r.json()["write_tools"] == ["create_issue", "add_comment"] assert r.json()["write_tools"] == [
"create_issue", "add_comment", "move_issue",
]
# ── Board : gestion des étiquettes ──────────────────────── # ── Board : gestion des étiquettes ────────────────────────