Compare commits

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

- move_issue (owner, repo, number, column) réutilise le service
  de déplacement de l'interface (_do_move_card, extrait de
  POST /api/move sans changement de comportement) : board
  local, journal source « mcp », miroir d'étiquette kanban:*
  remplacée, autres étiquettes préservées.
- Colonne acceptée par clé, par étiquette de forge ou par
  libellé français affiché (définitions de l'espace).
- Garde doctrine : la cible done (« Terminé ») est refusée
  avant toute écriture — les agents s'arrêtent en Révision.
- Erreurs propres : colonne inconnue (liste des valides),
  dépôt/issue inconnu, pull request refusée.
- 296 tests verts (5 nouveaux + épingles de la liste d'outils
  mises à jour).
2026-10-26 15:06:02 +00:00
Spark a92353f8ef feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s
- Clients : create_milestone / update_milestone (cache
  invalidé) ; GitHub hérite des mêmes endpoints.
- API Kanbante : création, modification (titre/échéance,
  échéance vidée = retirée), fermeture ; CSRF, validation
  de date avant toute écriture, erreurs forge en 502.
- Page Projet : formulaire 'Nouveau jalon' + édition/
  fermeture par jalon (project.js versionné).
- Échéance à midi UTC : à minuit, le fuseau de l'instance
  Gitea faisait reculer la date affichée d'un jour (réel).
- 291 tests verts (5 nouveaux, palier 24) ; cycle réel
  complet sur Projets/kanbante (jalons smoke #37/#38 créés,
  dates vérifiées, fermés).
2026-10-26 10:53:29 -04:00
Spark 8c889878ee feat(0.34.0): vue « En vol partout » /inflight (issue #24)
CI / test (push) Successful in 2m24s
- L'encours (colonnes en vol de l'espace) tous projets
  actifs, regroupé PAR colonne, trié par âge décroissant
  dans la colonne (pipeline _card_ages du board).
- En-tête : total + compteurs par colonne + limite WIP
  globale (somme des projets qui en définissent).
- Entrées Pilotage (menu latéral) et palette Ctrl+K ; SSE
  /api/stream via live.js comme les autres vues.
- Réutilise _collect_focus_groups : aucun appel forge en
  plus. 286 tests verts (5 nouveaux, palier 23) ; vérifié
  en Chromium réel (356 cartes en vol sur les vraies
  données, dont la carte #24 elle-même).
2026-10-26 10:48:45 -04:00
Spark dfb4e595ff fix(test): horodatage capturé une fois (flake CI, écart d'1 s)
CI / test (push) Successful in 2m23s
test_column_entry_times_ignores_comments évaluait _days_ago(3)
à la création puis à l'assertion : une bascule de seconde
entre les deux faisait échouer le run CI 2333 alors que le
run jumeau (tag v0.33.0) et la suite locale passaient.
2026-10-26 10:23:06 -04:00
Spark 9fe35f6c8f feat(0.33.0): filtrage professionnel sur Mes issues (/my)
CI / test (push) Successful in 2m26s
- Barre de filtres en carte : recherche texte (titre, projet,
  #numéro, étiquettes ; accents/casse ignorés, raccourci /),
  listes Projet et Étiquette avec compteurs, chips de
  colonnes colorées multi-sélection, 4 tris.
- Facettes calculées côté serveur ; filtrage instantané côté
  client (attributs data-*), compteur en direct, compteurs
  de section, état vide + réinitialisation.
- Filtres structurels persistés en localStorage.
- 281 tests verts (3 nouveaux, palier 22) ; vérifié en
  Chromium réel sur les vraies données (467 issues :
  recherche 'agent' -> 24 résultats instantanés).
2026-10-26 10:20:37 -04:00
Spark d419fe3086 fix(0.32.2): résolution par requête des URL dracodev, base agent dérivée et CSP du panneau
CI / test (push) Successful in 2m21s
La dérivation 0.32.1 ne couvrait que les URL sœurs depuis
AGENT_EVENEMENTS_URL, jamais agent_url : sur la dev, le panneau
visait le défaut prod (agent injoignable, prod non installée),
console morte. Désormais, pour tout rendu à une requête (barre,
iframe du panneau, CSP frame-src) : explicite > hôte public au
motif fermé dracodev > dérivation élargie (événements ou base du
service) > défaut officiel. Contextes sans requête inchangés
sinon ; aucun SSO ajouté.
2026-10-26 02:07:46 +00:00
Spark 65c467bc90 fix(0.32.1): URL sœurs dracodev dérivées de l'environnement de l'agent
CI / test (push) Successful in 2m58s
2026-10-25 23:49:33 +00:00
Spark 4a97e0d0dc refactor: noms publics dracodev par configuration (dev/prod)
CI / test (push) Successful in 2m22s
2026-10-25 18:02:21 +00:00
Spark 7852d860fe fix(0.31.1): compositeur de création refait, description en <details> natif
CI / test (push) Successful in 2m13s
- Carte compositeur : en-tête + colonne d'arrivée, champ
  pleine largeur, bouton primaire ; neutralise le
  display:flex imposé par .board-toolbar aux formulaires.
- 'Ajouter une description' devient un <details> natif :
  aucun JS requis, insensible au cache et aux erreurs de
  scripts (le bouton 0.31.0 pouvait rester inerte).
- Assets du board versionnés (?v=APP_VERSION) contre les
  mélanges de cache après redéploiement ; Ctrl+Entrée.
- Vérifié en Chromium réel sur le vrai board (captures
  contrôlées) ; 261 tests verts.
2026-10-23 09:48:17 -04:00
Spark d4cf8d1275 feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s
- Tiroir : édition du titre et de la description (PATCH
  forge, journal 'edited', EVENT_TYPES étendu), création
  avec description dépliable.
- Pièces jointes Gitea de bout en bout : client
  list/upload/delete/download (le téléchargement suit
  browser_download_url — l'endpoint asset ne sert que du
  JSON, attrapé au smoke réel), routes proxifiées avec
  CSRF et plafond 25 Mio, UI liste/envoi/suppression ;
  GitHub : supports_attachments=False, message propre.
- Clic carte en délégation d'événement : les écouteurs par
  carte mouraient au premier rafraîchissement live.
- 261 tests verts (7 nouveaux, palier 21) ; cycle réel
  complet validé sur une issue jetable du vrai Gitea
  (création, édition, envoi, téléchargement, suppression).
2026-10-23 09:27:38 -04:00
Spark 28322c12cf feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
  ordre libres, étiquette mappée + alias, drapeau Focus) ;
  définitions semées paresseusement depuis les valeurs
  historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
  étiquette→colonne (alias compris, ex. kanban_a_faire de
  Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
  capture, santé, focus, wall, dashboard branchés sur les
  colonnes de l'espace ; validations dynamiques dans models
  (move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
  renommer, réordonner, mapper, supprimer avec migration des
  cartes vers une colonne choisie, réinitialiser ; bouton
  🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
  le vrai Gitea : création, affichage au board, suppression.
2026-10-23 08:38:02 -04:00
41 changed files with 4261 additions and 239 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=
+383
View File
@@ -1,5 +1,388 @@
# Changelog # Changelog
## 0.37.0 — 2026-10-26
### Ajouté — tri « En attente de revue » sur le dashboard
Demande de Bruno : pouvoir ordonner les projets — section
**Épinglés** en tête — par ce qui attend sa validation.
- Nouveau critère dans le sélecteur de tri du dashboard,
en première place après « Par défaut » : **🔍 En attente
de revue ↓** — les projets s'ordonnent par le nombre de
cartes dans la colonne de validation (clé `review` de
l'espace), décroissant.
- Le compteur était déjà calculé pour les chips de
progression des cartes : il est maintenant exposé en
`data-review` sur chaque carte projet et consommé par le
moteur de tri partagé — le critère s'applique à **toutes
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
avec ou sans regroupement par propriétaire, et la
préférence est mémorisée comme les autres tris.
- Les critères existants (activité Gitea, activité kanban,
issues ouvertes, nom) sont inchangés.
### Corrigé — sens des tris numériques du dashboard
Le contrôle en navigateur réel a révélé que le tri
« Issues ouvertes ↓ » triait en réalité **croissant**
depuis son introduction : le comparateur partagé inversait
les clés numériques. Les modes numériques (`open` et le
nouveau `review`) trient maintenant en soustraction de
clés — le plus grand compteur en premier, comme l'annonce
le « ↓ ». Vérifié sur les données réelles : Épinglés
ordonnés 196, 95, 32, 5, 4 en attente de revue.
## 0.36.0 — 2026-10-26
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
Le service d'agents sait proposer et appliquer des déplacements
de cartes par approbation (`kanbante.move_card`), mais le serveur
MCP de Kanbante n'exposait en écriture que `create_issue` et
`add_comment` : un déplacement approuvé aurait échoué faute
d'outil. Le trou est fermé.
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
`number`, `column`) : déplace une carte vers une autre colonne
en réutilisant le service de déplacement de l'interface
(`_do_move_card`, extrait de `POST /api/move` sans changement
de comportement) — board local, journal des événements
(source « mcp »), émission vers le journal du service
d'agents et miroir d'étiquette.
- **Étiquette kanban remplacée, les autres préservées** : le
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
pose celle de la colonne cible ; les étiquettes libres de
l'issue (ex. « bug ») ne sont jamais touchées.
- **Colonne souple** : la clé (`review`), l'étiquette de forge
(`kanban:review`, alias d'espace compris) et le libellé
français affiché (« Révision », « À faire », « En cours »…)
sont acceptés ; les libellés viennent de la définition des
colonnes de l'espace, pas d'une table figée.
- **Garde doctrine** : la cible `done` (« Terminé ») est
**refusée avant toute écriture**, avec une erreur explicite —
Terminé appartient à Bruno dans son interface, les agents
s'arrêtent en Révision.
- **Erreurs propres** : colonne inconnue (avec la liste des
colonnes valides), dépôt ou issue inconnu, pull request
refusée comme n'étant pas une carte.
- Même garde que les autres outils /mcp (session, Basic ou
jeton d'API `kbt_`).
## 0.35.0 — 2026-10-26
### Ajouté — jalons en écriture depuis la page Projet (#26)
Seconde et dernière carte du backlog de la feuille de
route « Gestion de projets » : les jalons n'étaient que lus
dans Kanbante, ils s'écrivent maintenant — la forge reste
la source de vérité, rien n'est stocké localement.
- **Créer** un jalon (titre, description et échéance
optionnelles) depuis la page Projet, formulaire
« ➕ Nouveau jalon ».
- **Modifier** titre et échéance d'un jalon ouvert
(formulaire déplié par jalon ; échéance vidée = échéance
retirée, par un `null` explicite côté forge).
- **Fermer** un jalon, avec confirmation (ses issues
restent ouvertes).
- Écritures dans la forge via son API (`POST/PATCH
.../milestones`), CSRF vérifié, échéance invalide
refusée proprement **avant** toute écriture ; le board
filtré et le bandeau de progression reflètent le nouveau
jalon au chargement suivant, sans redémarrage.
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
dans le fuseau de son instance — à minuit UTC, l'échéance
affichée reculait d'un jour (attrapé au smoke réel).
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
réassigner les issues entre jalons.
## 0.34.0 — 2026-10-26
### Ajouté — vue « En vol partout » /inflight (issue #24)
Dernière carte du backlog de la feuille de route « Gestion
de projets », première du classement big picture (pilotage
quotidien des agents avant gestion de projet).
- **Page /inflight** : tout l'encours (colonnes en vol de
l'espace — En cours, QA, Révision par défaut) **tous
projets actifs confondus, regroupé par colonne** — là où
/focus groupe par projet et /my par assignation.
- Chaque carte : projet (lien board), titre, étiquettes,
assigné, et **âge dans la colonne** (badge ⏳, teinté
selon les seuils d'ancienneté) — les groupes sont **triés
par âge décroissant** : les plus vieilles cartes, le vrai
signal, en premier.
- En-tête : total en vol + compteurs par colonne ;
**limite WIP globale** affichée quand des projets en
définissent (somme par colonne).
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
palette Ctrl+K. Rafraîchissement doux par le SSE
`/api/stream` comme les autres vues (fermer une issue la
retire au rechargement).
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
`_card_ages`) : aucun appel forge supplémentaire.
## 0.33.0 — 2026-10-26
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
Demande de Bruno : retrouver vite une issue parmi toutes
celles qui lui sont assignées (467 ouvertes au moment du
test réel).
- **Barre de filtres** en carte, dans la langue visuelle du
compositeur 0.31.1 : recherche texte plein champ
(titre, projet, #numéro, étiquettes — insensible aux
accents et à la casse, raccourci « / », Échap pour
effacer), listes **Projet** et **Étiquette** avec
compteurs, **chips de colonnes** colorées avec compteurs
(multi-sélection), **tri** (activité récente, plus
anciennes, titre, projet).
- **Performance** : filtrage 100 % côté client sur des
attributs précalculés côté serveur — aucun aller-retour
réseau par frappe, application en requestAnimationFrame.
Compteur en direct (« 24 sur 467 issues »), compteurs de
section mis à jour, état vide propre avec réinitialisation
en un clic.
- **Persistance** : projet, colonnes, étiquette et tri sont
retenus en localStorage d'une visite à l'autre (la
recherche texte reste éphémère) ; bouton « Réinitialiser
les filtres » dès qu'un filtre est actif.
## 0.32.2 — 2026-10-25
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
Sur kanbante-dev, le mode console du panneau était une page morte
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
en `-dev`, le panneau recevait donc le défaut prod
`https://agent.dracodev.net` : iframe de console pointant un agent
prod pas encore installé, donc injoignable. Et aucune résolution
ne tenait compte de la requête : sans variable d'environnement,
un hôte public en `-dev` retombait entièrement sur la prod.
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
énoncée nulle part.
- **Résolution par requête** (`resoudre_urls_draco` dans
`app/config.py`, consommée par `render()` dans `app/main.py`)
pour tout ce qui est rendu à une requête — liens de la barre
(`draco_urls`), source de l'iframe du panneau
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
l'ordre : 1) la variable explicite de l'installation,
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
toutes les sœurs et à l'agent — jamais de suffixe inventé
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
suivante ; 3) la dérivation par les adresses de l'agent connues
en configuration, **élargie** : URL d'événements **ou** base du
service, selon ce que l'installation déclare ; 4) le défaut
officiel.
- **Base de l'agent dérivée** (contextes sans requête aussi) :
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
lieu de rester au défaut prod.
- **CSP nouvelle** : chaque page rendue énonce
`Content-Security-Policy: frame-src 'self' <origine agent>` —
exactement l'origine résolue pour cette même réponse, celle de
l'iframe de la console (seule iframe de l'application).
- Les contextes sans requête gardent la résolution par
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
n'en a pas par conception ; périmètre limité à ses liens et à
son panneau.
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
journal et dérivation élargie à la base du service ; contextes
sans requête inchangés. Suite complète : 278 tests réussis.
- **Déploiement** : cette version part avec le prochain
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
preuve en direct **en attente du redéploiement Docker de
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
## 0.32.1 — 2026-10-25
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
Sur la dev, le menu inter-apps pointait vers les noms **prod**
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
défaut officiel faute de variable explicite — il aurait fallu
déclarer quatre variables de plus dans le `.env` dev.
- **Résolution des URL sœurs** dans `app/config.py` (les
gabarits et le JS de la barre consomment `draco_urls`
inchangé), dans l'ordre : 1) la variable explicite —
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
d'environnement** depuis l'origine de l'agent telle que
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
deviné ; 3) le défaut officiel (prod), comme avant.
- L'URL Gitea ne change pas.
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
quatre URL sœurs servies sont en `-dev` ; une URL explicite
gagne sur la dérivation ; hôte d'agent hors motif et aucun
environnement posé restent aux défauts officiels
(non-régression).
## 0.32.0 — 2026-10-25
### Refactor — noms publics dracodev par configuration (dev/prod)
Aucun nom public dracodev n'est plus codé en dur dans le code
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
dont les défauts sont les noms **officiels** (prod). L'environnement
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
`AGENT_URL` quand il n'est pas posé explicitement (l'override
compose existant garde la priorité).
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
viennent de `draco_urls` injecté par `render()` ; le panneau
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
injecté avant `draco-panneau.js` (chargé avec le cache-busting
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
aucun nom : sans injection, il se déclare non configuré.
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
est construit depuis la configuration (`sso_authorize_url`).
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
document d'installation dev affirmant « pas de SSO par conception »
pour Kanbante — comportement existant conservé tel quel, juste
rendu configurable ; la contradiction documentaire est signalée,
pas tranchée ici.
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
- Tests barre/SSO mis en cohérence via la configuration (défauts
officiels + variantes `-dev`), `.env.example` créé, README et
`docker-compose.yml` documentent chaque variable touchée.
## 0.31.1 — 2026-10-23
### Corrigé — compositeur de création refait
Retour de Bruno sur la 0.31.0 : la barre de création
n'était ni élégante ni professionnelle, et le bouton
« + Description » pouvait rester sans effet (écouteur
JavaScript non enregistré selon l'état du cache ou des
scripts de la page).
- **Compositeur en carte** : en-tête « Nouvelle issue »
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
nom personnalisé de l'espace respecté), champ titre
pleine largeur avec anneau de focus, bouton primaire
« Créer l'issue ».
- **Description en `<details>` natif** : « Ajouter une
description ▾ » déplie la zone texte sans aucun
JavaScript — impossible à casser par un script en
erreur ou un fichier en cache. Aide intégrée (Markdown,
Ctrl+Entrée pour créer, actif aussi depuis le titre).
- **Assets du board versionnés** (`?v=<version>` sur le
CSS et board.js du board) : un redéploiement invalide
le cache navigateur au lieu de servir un mélange
d'anciennes et nouvelles pièces.
- Vérifié en navigateur réel (Chromium) sur le vrai
board : captures fermé/ouvert contrôlées visuellement,
clic sur le libellé, zone visible et éditable.
## 0.31.0 — 2026-10-23
### Ajouté — description éditable, pièces jointes, clic fiable
Retours de Bruno : impossible d'ajouter une description
après création, aucun moyen de joindre des fichiers comme
dans Gitea, et clic sur les cartes capricieux.
- **Titre et description éditables** dans le tiroir de
détail (bouton « ✏️ Modifier le titre et la description ») :
l'enregistrement écrit directement dans l'issue de la
forge (PATCH), le journal consigne « edited ✏️ », et la
carte du board reflète le nouveau titre sans recharger.
- **Description dès la création** : le formulaire « Nouvelle
issue » gagne un bouton « + Description » qui déplie un
champ texte transmis à la création.
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
liste des fichiers (nom, taille, date), **ajout** par le
sélecteur de fichier (25 Mio max), **téléchargement**
proxifié par Kanbante (le jeton ne quitte jamais le
serveur ; suit le `browser_download_url` de Gitea, dont
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
au smoke réel), **suppression** avec confirmation. Sur
GitHub, l'API ne gère pas les pièces jointes d'issues :
l'écran l'explique et renvoie à l'interface web.
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
passe en **délégation d'événement** — avant, l'écouteur
était posé carte par carte au chargement ; le premier
rafraîchissement temps réel remplaçait les fragments et
les cartes devenaient muettes. Le seuil anti-glisser
(6 px) et l'exclusion liens/boutons sont conservés, et
les cartes affichent un curseur pointeur.
## 0.30.0 — 2026-10-23
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
des colonnes (Backlog, À faire, En cours, …) et configurer le
mapping label/colonne depuis l'interface web.
- **Colonnes par espace** (Administration → Colonnes, ou le
bouton 🧱 Colonnes sur un board) : ajouter une colonne
(insérée avant Terminé), la renommer (le nom affiché est
libre, émojis compris), changer sa couleur, la monter ou la
descendre, la supprimer — ses cartes sont alors déplacées
vers une colonne de destination choisie. Réinitialisation
aux six colonnes historiques en un clic. Chaque espace a SES
colonnes.
- **Clés immuables** : une colonne garde sa clé interne
(`en_developpement`) quoi qu'on renomme — les cartes, les
limites WIP et les règles y restent rattachés. Backlog et
Terminé sont des colonnes système (renommables, non
supprimables) : défaut des cartes neuves, et issues fermées.
- **Mapping étiquette ↔ colonne configurable** : chaque
colonne de flux a son **étiquette mappée** (défaut
`kanban:<clé>`) posée sur l'issue à chaque déplacement
(miroir), plus des **alias** reconnus à l'adoption — par
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
alias de la colonne À faire fait adopter cette colonne par
le board. Adoption : exactement une étiquette reconnue,
comme avant.
- **Focus / Wallboard** : chaque colonne déclare si elle y
figure (case « Dans Focus / Wallboard ») ; les nouvelles
colonnes y sont par défaut.
- Sans personnalisation, tout est strictement identique à
avant (définitions semées paresseusement depuis les valeurs
historiques). Le triage au clavier garde ses touches
historiques (1 = À faire, 2 = En cours, …).
## 0.29.0 — 2026-10-23 ## 0.29.0 — 2026-10-23
### Modifié — panneau Spark V3 : la console en surimpression pleine page ### Modifié — panneau Spark V3 : la console en surimpression pleine page
+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 |
+136
View File
@@ -0,0 +1,136 @@
"""Colonnes de board personnalisables par espace (0.30.0).
Avant : six colonnes codées en dur (backlog, todo, in_progress,
qa, review, done) et un mapping fixe colonne → étiquette
``kanban:<colonne>``. Maintenant, chaque espace de travail
définit ses colonnes : nom d'affichage, couleur, ordre,
étiquette de forge mappée et alias reconnus. Les clés sont
immuables (les données board_state les référencent) ; backlog
et done restent des colonnes système (renommables, non
supprimables) parce qu'elles portent une sémantique : défaut
des cartes neuves, et état fermé des issues.
Sans définition propre, un espace retombe sur les défauts
historiques — comportement 0.24 strictement conservé.
"""
from __future__ import annotations
from app import models
DEFAULT_COLUMNS: list[dict] = [
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "", "kind": "flow",
"in_focus": 0},
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
"label": "kanban:qa", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
"label": "kanban:review", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
]
_cache: dict[int, list[dict]] = {}
def invalidate(workspace_id: int | None = None) -> None:
if workspace_id is None:
_cache.clear()
else:
_cache.pop(workspace_id, None)
def ensure_columns(workspace_id: int) -> list[dict]:
"""Seed the historical defaults if the workspace has none."""
existing = models.list_board_columns(workspace_id)
if existing:
return existing
for position, column in enumerate(DEFAULT_COLUMNS):
models.upsert_board_column(
workspace_id, {**column, "position": position}
)
invalidate(workspace_id)
return models.list_board_columns(workspace_id)
def get_columns(workspace_id: int = 1) -> list[dict]:
"""Effective columns of a workspace (cached, seeded lazily)."""
if workspace_id not in _cache:
_cache[workspace_id] = ensure_columns(workspace_id)
return _cache[workspace_id]
def keys(workspace_id: int = 1) -> list[str]:
return [c["key"] for c in get_columns(workspace_id)]
def names(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
def colors(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
def flow_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
]
def focus_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
]
def wall_keys(workspace_id: int = 1) -> list[str]:
"""Wallboard cards: focus columns + the first flow column."""
columns = get_columns(workspace_id)
wanted = {c["key"] for c in columns if c.get("in_focus")}
flow = [c["key"] for c in columns if c["kind"] == "flow"]
if flow:
wanted.add(flow[0])
return [c["key"] for c in columns if c["key"] in wanted]
def label_for(workspace_id: int, key: str) -> str | None:
"""The forge label mirrored when a card enters this column."""
for column in get_columns(workspace_id):
if column["key"] == key and column["kind"] == "flow":
return column.get("label") or None
return None
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
"""label name (lowercased) → column key, aliases included.
This is the configurable label/column mapping: the canonical
label of each flow column plus every alias declared for it
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
"""
lookup: dict[str, str] = {}
for column in get_columns(workspace_id):
if column["kind"] != "flow":
continue
names = [column.get("label") or ""]
names += [
a.strip() for a in (column.get("aliases") or "").split(",")
]
for name in names:
name = name.strip().lower()
if name:
lookup[name] = column["key"]
return lookup
def recognized_labels(workspace_id: int = 1) -> set[str]:
"""Every label name (lowercased) owned by the mapping."""
return set(label_lookup(workspace_id))
+254 -7
View File
@@ -96,10 +96,72 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after 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.29.0" APP_VERSION = "0.37.0"
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.24.0"}, "clientInfo": {"name": "kanbante", "version": APP_VERSION},
}, },
req_id=1, req_id=1,
) )
+135
View File
@@ -48,6 +48,7 @@ class GiteaClient:
""" """
kind = "gitea" kind = "gitea"
supports_attachments = True
api_suffix = "/api/v1" api_suffix = "/api/v1"
auth_scheme = "token" auth_scheme = "token"
@@ -476,6 +477,59 @@ class GiteaClient:
), ),
) )
async def create_milestone(
self,
owner: str,
repo: str,
title: str,
description: str = "",
due_on: str | None = None,
) -> dict:
"""Create a milestone on the forge (issue #26)."""
payload: dict = {"title": title, "description": description}
if due_on:
payload["due_on"] = due_on
r = await self.client.post(
f"/repos/{owner}/{repo}/milestones", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def update_milestone(
self,
owner: str,
repo: str,
milestone_id: int,
title: str | None = None,
description: str | None = None,
due_on: str | None = None,
clear_due: bool = False,
state: str | None = None,
) -> dict:
"""Patch a milestone (title, due date, state) — #26.
due_on=None + clear_due=False leaves the date alone;
clear_due=True sends an explicit null (Gitea clears it).
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if clear_due:
payload["due_on"] = None
elif due_on:
payload["due_on"] = due_on
if state is not None:
payload["state"] = state
r = await self.client.patch(
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
async def list_starred(self) -> list[dict]: 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)."""
@@ -503,6 +557,87 @@ class GiteaClient:
self._cache_clear() self._cache_clear()
return r.json() return r.json()
async def update_issue(
self,
owner: str,
repo: str,
number: int,
title: str | None = None,
body: str | None = None,
) -> dict:
"""Edit an issue's title and/or body (0.31.0)."""
payload: dict = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if not payload:
return await self.get_issue(owner, repo, number)
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{number}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Pièces jointes des issues (0.31.0) ────────────────
async def list_issue_assets(
self, owner: str, repo: str, number: int
) -> list[dict]:
"""Attachments of an issue (Gitea assets)."""
return await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets"
)
async def upload_issue_asset(
self,
owner: str,
repo: str,
number: int,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> dict:
"""Attach a file to an issue; returns the asset record."""
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/assets",
files={"attachment": (filename, content, content_type)},
)
r.raise_for_status()
return r.json()
async def delete_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
r.raise_for_status()
async def download_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> tuple[bytes, str]:
"""(content, content_type) of one attachment.
Gitea nuance (vérifiée en réel sur 1.27.3): GET on the
asset endpoint returns its JSON record, NOT the bytes —
the file itself lives at `browser_download_url`
(/attachments/<uuid>), fetched here with the connection's
token so private repos work too.
"""
record = await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
url = record.get("browser_download_url") if isinstance(record, dict) else None
if not url:
raise ValueError("La forge ne fournit pas d'URL de téléchargement")
r = await self.client.get(url)
r.raise_for_status()
return r.content, r.headers.get(
"content-type", "application/octet-stream"
)
async def set_issue_state( async def set_issue_state(
self, owner: str, repo: str, issue_number: int, state: str self, owner: str, repo: str, issue_number: int, state: str
) -> dict: ) -> dict:
+4
View File
@@ -27,6 +27,10 @@ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient): class GitHubClient(GiteaClient):
kind = "github" kind = "github"
# L'API GitHub ne sait pas joindre de fichiers aux issues
# (réservé à son interface web) : les routes Kanbante
# répondent proprement « non pris en charge » (0.31.0).
supports_attachments = False
api_suffix = "" api_suffix = ""
auth_scheme = "Bearer" auth_scheme = "Bearer"
+1123 -174
View File
File diff suppressed because it is too large Load Diff
+137 -7
View File
@@ -154,6 +154,21 @@ def init_db() -> None:
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ── Colonnes personnalisables par espace (0.30.0) ──
CREATE TABLE IF NOT EXISTS board_columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
aliases TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'flow',
in_focus INTEGER NOT NULL DEFAULT 0,
UNIQUE(workspace_id, key)
);
CREATE TABLE IF NOT EXISTS workspace_connections ( CREATE TABLE IF NOT EXISTS workspace_connections (
workspace_id INTEGER NOT NULL, workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL, connection_id INTEGER NOT NULL,
@@ -219,7 +234,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
# get_board_state() with a KeyError. Drop them once, here. # get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS) placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
conn.execute( conn.execute(
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})", f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders}) "
"AND NOT EXISTS (SELECT 1 FROM board_columns bc "
"WHERE bc.workspace_id = board_state.workspace_id "
"AND bc.key = board_state.column_name)",
KANBAN_COLUMNS, KANBAN_COLUMNS,
) )
@@ -340,7 +358,9 @@ def get_board_state(
(workspace_id, connection_id, repo_full_name), (workspace_id, connection_id, repo_full_name),
).fetchall() ).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} columns: dict[str, list[dict]] = {
col: [] for col in column_keys(workspace_id)
}
for row in rows: for row in rows:
column = row["column_name"] column = row["column_name"]
if column not in columns: if column not in columns:
@@ -364,7 +384,7 @@ def move_issue(
connection_id: int = 1, connection_id: int = 1,
) -> None: ) -> None:
"""Move an issue to a different column (appended at the end).""" """Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS: if to_column not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {to_column}") raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
@@ -400,7 +420,7 @@ def set_column_order(
its index in the list. Used after a drag & drop so manual ordering its index in the list. Used after a drag & drop so manual ordering
survives reloads. survives reloads.
""" """
if column_name not in KANBAN_COLUMNS: if column_name not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {column_name}") raise ValueError(f"Invalid column: {column_name}")
with get_db() as conn: with get_db() as conn:
for index, issue_number in enumerate(ordered_issue_numbers): for index, issue_number in enumerate(ordered_issue_numbers):
@@ -583,7 +603,7 @@ def latest_snapshot_at() -> str | None:
# ── Event journal ────────────────────────────────────────── # ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule") EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited")
def log_event( def log_event(
@@ -793,11 +813,10 @@ def get_stats() -> dict:
projects: dict[str, dict[str, int]] = {} projects: dict[str, dict[str, int]] = {}
for row in rows: for row in rows:
if row["column_name"] not in KANBAN_COLUMNS:
continue
name = row["repo_full_name"] name = row["repo_full_name"]
if name not in projects: if name not in projects:
projects[name] = {col: 0 for col in KANBAN_COLUMNS} projects[name] = {col: 0 for col in KANBAN_COLUMNS}
# Les colonnes personnalisées (0.30.0) s'ajoutent au vol.
projects[name][row["column_name"]] = row["cnt"] projects[name][row["column_name"]] = row["cnt"]
return projects return projects
@@ -1253,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
(limit,), (limit,),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ── Colonnes de board personnalisées (0.30.0) ─────────────
# Définitions PAR ESPACE : clé immuable, nom/couleur libres,
# étiquette forge mappée (+ alias reconnus à l'adoption).
# Sans lignes pour un espace, column_keys retombe sur les
# défauts historiques — comportement 0.24 strictement conservé.
def list_board_columns(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM board_columns WHERE workspace_id = ? "
"ORDER BY position, id",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def column_keys(workspace_id: int = 1) -> list[str]:
"""Ordered column keys of a workspace (defaults if unset)."""
cols = list_board_columns(workspace_id)
if not cols:
return list(KANBAN_COLUMNS)
return [c["key"] for c in cols]
def upsert_board_column(workspace_id: int, column: dict) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO board_columns
(workspace_id, key, name, color, position, label,
aliases, kind, in_focus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, key) DO UPDATE SET
name = excluded.name, color = excluded.color,
position = excluded.position, label = excluded.label,
aliases = excluded.aliases, kind = excluded.kind,
in_focus = excluded.in_focus""",
(
workspace_id, column["key"], column["name"],
column.get("color", "#6b7280"),
int(column.get("position", 0)),
column.get("label", ""), column.get("aliases", ""),
column.get("kind", "flow"),
1 if column.get("in_focus") else 0,
),
)
def delete_board_column(workspace_id: int, key: str) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ? AND key = ?",
(workspace_id, key),
)
def clear_board_columns(workspace_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ?",
(workspace_id,),
)
def count_column_cards(workspace_id: int, key: str) -> int:
with get_db() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM board_state "
"WHERE workspace_id = ? AND column_name = ?",
(workspace_id, key),
).fetchone()
return int(row["n"]) if row else 0
def migrate_column_cards(
workspace_id: int, from_key: str, to_key: str
) -> int:
"""Move every card of a column to another (column deletion).
Positions are appended after the target column's current
maximum, per repository and connection.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM board_state WHERE workspace_id = ? AND column_name = ? "
"ORDER BY position, issue_number",
(workspace_id, from_key),
).fetchall()
for row in rows:
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS p FROM board_state "
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND column_name = ?",
(
workspace_id, row["connection_id"],
row["repo_full_name"], to_key,
),
).fetchone()
conn.execute(
"UPDATE board_state SET column_name = ?, position = ?, "
"updated_at = datetime('now') WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ? "
"AND issue_number = ?",
(
to_key, nxt["p"], workspace_id, row["connection_id"],
row["repo_full_name"], row["issue_number"],
),
)
return len(rows)
+4 -1
View File
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
import json import json
from app import models
from app.models import KANBAN_COLUMNS from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"} CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"(fermer/déplacer une carte par règle est interdit en v1)" "(fermer/déplacer une carte par règle est interdit en v1)"
) )
continue continue
if "column" in when and when["column"] not in KANBAN_COLUMNS: if "column" in when and when["column"] not in (
set(KANBAN_COLUMNS) | set(models.column_keys(1))
):
errors.append(f"{name} : colonne inconnue « {when['column']} »") errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue continue
if "min_age_days" in when and ( if "min_age_days" in when and (
+11 -7
View File
@@ -1,7 +1,10 @@
{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28). {# 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>
+2
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"),
@@ -38,6 +39,7 @@
("admin", "/admin", "🛠️", "Vue d'ensemble"), ("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"), ("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"), ("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"), ("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]), ]),
] %} ] %}
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Colonnes — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧱 Colonnes du board</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>Comment marche le mapping</h2>
<p class="notes-hint">
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
</p>
</section>
{% for c in columns %}
<section class="briefing-card">
<h2>
<span class="ws-dot" style="background: {{ c.color }}"></span>
{{ c.name }}
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
</h2>
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
{% if c.kind == 'flow' %}
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
{% endif %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
</form>
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
</form>
{% if c.kind == 'flow' %}
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Déplacer ses cartes vers
<select name="target">
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
</select>
</label>
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
</form>
{% endif %}
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle colonne</h2>
<form method="post" action="/admin/columns" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
</form>
</section>
<section class="briefing-card">
<h2>↩️ Réinitialiser</h2>
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
</form>
</section>
</main>
</body>
</html>
+39 -6
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" %}
@@ -53,16 +53,31 @@
</select> </select>
<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>
<form id="create-issue-form"> {% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<form id="create-issue-form" class="composer">
<div class="composer-head">
<span class="composer-title">➕ Nouvelle issue</span>
<span class="composer-target">arrive dans
<strong>{{ column_labels.backlog }}</strong>
</span>
</div>
<div class="composer-main">
<input type="text" id="new-issue-title" maxlength="255" <input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required> placeholder="Titre de l'issue..." required>
<button type="submit" class="btn-save btn-inline">Créer</button> <button type="submit" class="btn-save btn-inline">Créer l'issue</button>
</div>
<details class="composer-desc" id="new-issue-desc">
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
<textarea id="new-issue-body" rows="5" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
</details>
</form> </form>
</div> </div>
<main class="board-container"> <main class="board-container">
<div class="kanban-board"> <div class="kanban-board">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %} {% for col in column_keys %}
<div class="kanban-column" <div class="kanban-column"
data-column="{{ col }}"> data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}"> <div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
@@ -145,6 +160,24 @@
</div> </div>
<div id="detail-code" class="detail-code"></div> <div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div> <div id="detail-body" class="detail-body"></div>
<div class="detail-actions">
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
</div>
<form id="detail-edit-form" hidden>
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
<textarea id="detail-body-input" rows="10" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<div class="detail-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
</div>
</form>
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
<div id="detail-assets"></div>
<form id="asset-form" class="asset-form">
<input type="file" id="asset-file">
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
</form>
<h3 class="detail-comments-title">💬 Commentaires</h3> <h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div> <div id="detail-comments"></div>
<form id="comment-form"> <form id="comment-form">
+3 -1
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())">
@@ -146,7 +148,7 @@
{% endif %} {% endif %}
{% if full in stats %} {% if full in stats %}
<div class="mini-progress"> <div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %} {% for col in column_keys %}
{% set n = stats[full].get(col, 0) %} {% set n = stats[full].get(col, 0) %}
{% if n > 0 %} {% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}"> <span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>En vol partout — Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
<div class="inflight-summary">
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
{% for group in groups %}
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
</span>
{% endfor %}
</div>
{% if not groups %}
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
{% endif %}
{% for group in groups %}
<section class="focus-group">
<h2>
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
{{ column_labels[group.column] }}
<span class="badge secondary">{{ group.cards|length }}</span>
{% if wip_globals.get(group.column) %}
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
{% endif %}
</h2>
<div class="focus-cards">
{% for card in group.cards %}
<div class="kanban-card focus-card">
<div class="card-id">
#{{ card.issue.number }}
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
{% if card.issue.html_url %}
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
{% endif %}
</div>
<div class="card-title">{{ card.issue.title }}</div>
{% if card.issue.labels %}
<div class="card-labels">
{% for label in card.issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if card.issue.assignee %}
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
{% endif %}
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
</div>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</main>
<footer>
Kanbante v{{ version }} · en vol partout
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -29,7 +29,7 @@
</label> </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.29.0" version = "0.37.0"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+67
View File
@@ -1683,3 +1683,70 @@ body.sidebar-collapsed .ws-switch { display: none; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px; .conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; } border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; } .seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
.kanban-card { cursor: pointer; }
.detail-actions { margin: 10px 0; }
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
#detail-edit-form textarea { width: 100%; }
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
.asset-row a { text-decoration: none; font-weight: 600; }
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
/* ── Compositeur de nouvelle issue (0.31.1) ── */
/* La toolbar impose display:flex à ses formulaires : le
compositeur est une carte, en bloc et pleine largeur. */
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.composer-title { font-weight: 700; font-size: 0.95rem; }
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
.composer-main { display: flex; gap: 8px; }
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-desc { margin-top: 10px; }
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
.composer-desc summary::-webkit-details-marker { display: none; }
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
/* ── Mes issues : barre de filtres (0.33.0) ── */
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.my-filters-row + .my-filters-row { margin-top: 10px; }
.my-search-wrap { position: relative; flex: 1 1 280px; }
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
.my-filters select:focus { outline: none; border-color: var(--accent); }
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
.filter-chip:hover { border-color: var(--border-strong); }
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
/* ── En vol partout (0.34.0, issue #24) ── */
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
.inflight-total { font-weight: 700; margin-right: 4px; }
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
.ms-edit { margin-top: 8px; }
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
.ms-edit summary::-webkit-details-marker { display: none; }
.ms-edit summary:hover { text-decoration: underline; }
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
.ms-form-actions { display: flex; gap: 8px; }
+152 -5
View File
@@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData(); const formData = new FormData();
formData.append('repo_full_name', repoFullName); formData.append('repo_full_name', repoFullName);
formData.append('title', title); formData.append('title', title);
const bodyInput = document.getElementById('new-issue-body');
if (bodyInput && bodyInput.value.trim()) {
formData.append('body', bodyInput.value.trim());
}
formData.append('column', 'backlog'); formData.append('column', 'backlog');
formData.append('csrf_token', csrfToken); formData.append('csrf_token', csrfToken);
try { try {
@@ -118,6 +122,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
createForm?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
createForm.requestSubmit();
}
});
// The "Glissez une carte ici" hint is rendered by the server only // The "Glissez une carte ici" hint is rendered by the server only
// for columns empty at page load. After a drag, the DOM is the // for columns empty at page load. After a drag, the DOM is the
// truth: remove the hint from columns that hold cards, restore it // truth: remove the hint from columns that hold cards, restore it
@@ -323,14 +334,17 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Card detail drawer ───────────────────────────────── // ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel. // A click opens the panel; a drag must not. Track pointer travel.
// DELEGATED on purpose (0.31.0): the live board swaps card
// fragments in place — listeners attached per card died at the
// first refresh, which made cards "hard to click". One listener
// on the document keeps working whatever the DOM becomes.
let downX = 0, downY = 0; let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; }); document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => { document.addEventListener('click', (e) => {
card.addEventListener('click', (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (e.target.closest('a')) return; if (e.target.closest('a') || e.target.closest('button')) return;
openDetail(card.dataset.repo, card.dataset.issue); const card = e.target.closest('.kanban-card');
}); if (card) openDetail(card.dataset.repo, card.dataset.issue);
}); });
const drawer = document.getElementById('card-detail'); const drawer = document.getElementById('card-detail');
@@ -355,6 +369,9 @@ document.addEventListener('DOMContentLoaded', () => {
overlay?.addEventListener('click', closeDetail); overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
let currentComments = [];
let currentIssueData = null;
async function openDetail(repoFull, number) { async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2); const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number }; currentIssue = { repo: repoFull, number };
@@ -368,6 +385,8 @@ document.addEventListener('DOMContentLoaded', () => {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`); const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
currentComments = data.comments || [];
currentIssueData = data.issue;
fillDetail(data.issue, data.comments); fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number); loadCodeLinks(repoFull, number);
} catch (err) { } catch (err) {
@@ -432,6 +451,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function fillDetail(issue, comments) { function fillDetail(issue, comments) {
currentIssueData = issue;
document.getElementById('detail-title').textContent = issue.title; document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state'); const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte'; stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
@@ -457,6 +477,8 @@ document.addEventListener('DOMContentLoaded', () => {
renderLabelToggles(issue); renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML = document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>'; issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
renderAssets(issue);
setEditMode(false);
document.getElementById('detail-gitea-link').href = issue.html_url || '#'; document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments'); const box = document.getElementById('detail-comments');
box.innerHTML = ''; box.innerHTML = '';
@@ -520,6 +542,131 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
function setEditMode(on) {
const form = document.getElementById('detail-edit-form');
const btn = document.getElementById('detail-edit-btn');
const title = document.getElementById('detail-title');
const body = document.getElementById('detail-body');
if (!form || !btn) return;
form.hidden = !on;
btn.hidden = on;
if (title) title.hidden = on;
if (body) body.hidden = on;
if (on && currentIssueData) {
document.getElementById('detail-title-input').value = currentIssueData.title || '';
document.getElementById('detail-body-input').value = currentIssueData.body || '';
}
}
document.getElementById('detail-edit-btn')?.addEventListener('click', () => setEditMode(true));
document.getElementById('detail-edit-cancel')?.addEventListener('click', () => setEditMode(false));
document.getElementById('detail-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const title = document.getElementById('detail-title-input').value.trim();
const body = document.getElementById('detail-body-input').value;
if (!title) { showToast('❌ Le titre ne peut pas être vide', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('title', title);
fd.append('body', body);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/update`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, currentComments);
const card = document.querySelector(`.kanban-card[data-issue="${currentIssue.number}"]`);
if (card) {
const t = card.querySelector('.card-title');
if (t) t.textContent = data.issue.title;
card.dataset.title = (data.issue.title || '').toLowerCase();
}
showToast('✅ Issue mise à jour');
} catch (err) { showToast('❌ ' + err.message, true); }
});
function humanSize(bytes) {
if (!bytes) return '0 o';
const units = ['o', 'Kio', 'Mio', 'Gio'];
let i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
}
function renderAssets(issue) {
const box = document.getElementById('detail-assets');
const form = document.getElementById('asset-form');
if (!box) return;
box.innerHTML = '';
if (!issue.attachments_supported) {
box.innerHTML = '<p class="detail-empty">Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.</p>';
if (form) form.hidden = true;
return;
}
if (form) form.hidden = false;
const assets = issue.assets || [];
if (!assets.length) {
box.innerHTML = '<p class="detail-empty">Aucune pièce jointe.</p>';
return;
}
const [owner, repo] = (currentIssue?.repo || '/').split('/', 2);
assets.forEach(a => {
const row = document.createElement('div');
row.className = 'asset-row';
const link = document.createElement('a');
link.href = `/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/download`;
link.textContent = '📎 ' + a.name;
link.title = 'Télécharger';
row.appendChild(link);
const meta = document.createElement('span');
meta.className = 'asset-meta';
meta.textContent = humanSize(a.size) + (a.created_at ? ' · ' + String(a.created_at).slice(0, 10) : '');
row.appendChild(meta);
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-mini btn-danger';
del.textContent = 'Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer la pièce jointe « ' + a.name + ' » ?')) return;
const fd = new FormData();
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/delete`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
showToast('✅ Pièce jointe supprimée');
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
}
document.getElementById('asset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const input = document.getElementById('asset-file');
const file = input?.files?.[0];
if (!file) { showToast('❌ Choisis un fichier d\'abord', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('attachment', file);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
input.value = '';
showToast('✅ Fichier joint : ' + data.asset.name);
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
function commentNode(c) { function commentNode(c) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'comment'; div.className = 'comment';
+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;
+14 -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,7 +32,11 @@
* 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 = "";
@@ -251,7 +255,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">' +
@@ -328,6 +332,12 @@
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 +356,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é.");
}); });
} }
+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);
}
});
});
})();
+97 -2
View File
@@ -187,6 +187,52 @@ class FakeGitea:
"404", request=request, response=_httpx.Response(404, request=request) "404", request=request, response=_httpx.Response(404, request=request)
) )
supports_attachments = True
async def update_issue(self, owner, repo, number, title=None, body=None):
issue = await self.get_issue(owner, repo, number)
if title is not None:
issue["title"] = title
if body is not None:
issue["body"] = body
return issue
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
@property
def _assets(self):
return self.__dict__.setdefault("assets_store", {})
async def list_issue_assets(self, owner, repo, number):
return list(self._assets.get((f"{owner}/{repo}", number), []))
async def upload_issue_asset(
self, owner, repo, number, filename, content, content_type="application/octet-stream"
):
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
asset = {
"id": 700 + len(store) + number,
"name": filename,
"size": len(content),
"download_count": 0,
"created_at": "2026-10-23T10:00:00Z",
"_content": content,
"_type": content_type,
}
store.append(asset)
return {k: v for k, v in asset.items() if not k.startswith("_")}
async def delete_issue_asset(self, owner, repo, number, asset_id):
store = self._assets.get((f"{owner}/{repo}", number), [])
self._assets[(f"{owner}/{repo}", number)] = [
a for a in store if a["id"] != asset_id
]
async def download_issue_asset(self, owner, repo, number, asset_id):
for asset in self._assets.get((f"{owner}/{repo}", number), []):
if asset["id"] == asset_id:
return asset["_content"], asset["_type"]
raise AssertionError("asset introuvable dans le faux")
async def get_issue_comments(self, owner, repo, number): async def get_issue_comments(self, owner, repo, number):
return list(self.comments.get((f"{owner}/{repo}", number), [])) return list(self.comments.get((f"{owner}/{repo}", number), []))
@@ -313,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 ─────────────────────────────────────────
+277
View File
@@ -0,0 +1,277 @@
"""Palier 20 (0.30.0): colonnes personnalisables par
espace et mapping étiquette ↔ colonne configurable."""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import board_columns as bcols
from app import models
from tests.conftest import csrf_from
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
yield client
@pytest.fixture
def ws_cols():
"""Un espace dédié rattaché à la connexion héritée."""
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
models.set_workspace_connections(ws_id, [1])
bcols.invalidate(ws_id)
yield ws_id
models.clear_board_columns(ws_id)
bcols.invalidate(ws_id)
models.delete_workspace(ws_id)
def _issue(number, title, labels=None):
return {
"number": number,
"title": title,
"body": "",
"state": "open",
"labels": labels or [],
"assignee": None,
"assignees": [],
"milestone": None,
"due_date": None,
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-02T09:00:00Z",
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
"pull_request": None,
}
# ── Défauts et service ────────────────────────────────────
def test_defaults_match_historical_columns(ws_cols):
columns = bcols.get_columns(ws_cols)
assert [c["key"] for c in columns] == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
assert bcols.label_for(ws_cols, "review") == "kanban:review"
assert bcols.label_for(ws_cols, "backlog") is None
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
assert bcols.wall_keys(ws_cols) == [
"todo", "in_progress", "qa", "review",
]
def test_custom_column_at_model_level(ws_cols):
columns = bcols.get_columns(ws_cols)
models.upsert_board_column(
ws_cols,
{
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
"position": 4, "label": "etape:dev", "aliases": "",
"kind": "flow", "in_focus": 1,
},
)
bcols.invalidate(ws_cols)
assert "dev" in models.column_keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert [c["issue_number"] for c in state["dev"]] == [5]
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
assert "dev" in bcols.focus_keys(ws_cols)
# Un autre espace n'est pas affecté.
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
try:
assert "dev" not in models.column_keys(other)
finally:
models.delete_workspace(other)
# ── Panneau d'administration ──────────────────────────────
def test_admin_columns_crud(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
page = auth_client.get("/admin/columns")
assert page.status_code == 200
assert "Colonnes du board" in page.text
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns",
data={
"name": "🛠️ En développement",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
keys = bcols.keys(ws_cols)
assert "en_developpement" in keys
# Insérée avant « done ».
assert keys.index("en_developpement") == keys.index("done") - 1
# Renommage + mapping via le formulaire de la colonne.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/en_developpement",
data={
"name": "🚀 Dev",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev, dev",
"in_focus": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
lookup = bcols.label_lookup(ws_cols)
assert lookup["etape:dev"] == "en_developpement"
assert lookup["en-dev"] == "en_developpement"
# Le board affiche la nouvelle colonne.
board = auth_client.get("/board/Projets/demo")
assert 'data-column="en_developpement"' in board.text
assert "🚀 Dev" in board.text
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Attente client", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "attente_client" in bcols.keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "attente_client",
workspace_id=ws_cols, connection_id=1,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns/attente_client/delete",
data={"target": "backlog", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert "attente_client" not in bcols.keys(ws_cols)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 5 for c in state["backlog"])
# Les colonnes système ne se suppriment pas.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/backlog/delete",
data={"target": "todo", "csrf_token": token},
follow_redirects=False,
)
assert "backlog" in bcols.keys(ws_cols)
def test_admin_columns_reset(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Temporaire", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "temporaire" in bcols.keys(ws_cols)
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/reset",
data={"csrf_token": token},
follow_redirects=False,
)
assert bcols.keys(ws_cols) == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
# ── Mapping : adoption par alias et miroir personnalisé ──
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
colonne todo, fait adopter À faire par le board."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/todo",
data={
"name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "kanban_a_faire",
"csrf_token": token,
},
follow_redirects=False,
)
fake_gitea.issues["Projets/demo"].append(
_issue(42, "Carte Flowdeck", labels=[
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
])
)
payload = auth_client.get("/api/board/Projets/demo").json()
assert "42" in payload["columns"]["todo"]["html"]
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 42 for c in state["todo"])
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "En développement", "color": "#224466",
"label": "etape:dev", "aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": "5",
"to_column": "en_developpement",
"csrf_token": token,
},
)
assert r.status_code == 200
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
names = [l["name"] for l in issue.get("labels") or []]
assert "etape:dev" in names
+136
View File
@@ -0,0 +1,136 @@
"""Palier 21 (0.31.0): description éditable, pièces jointes
d'issues, clic carte robuste (délégation côté JS)."""
from tests.conftest import csrf_from
def test_issue_update_title_and_body(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/update",
data={
"title": "Tâche un — précisée",
"body": "## Contexte\n\nLa description qui manquait.",
"csrf_token": token,
},
)
assert r.status_code == 200
payload = r.json()
assert payload["ok"] is True
assert payload["issue"]["body"].startswith("## Contexte")
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1
)
assert issue["title"] == "Tâche un — précisée"
assert "description qui manquait" in issue["body"]
# Le journal consigne l'édition.
events = client.get("/api/events?repo=Projets/demo").json()["events"]
assert any(e["event_type"] == "edited" for e in events)
def test_issue_update_rejects_empty_title(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/update",
data={"title": " ", "body": "x", "csrf_token": token},
)
assert r.status_code == 400
def test_issue_detail_includes_assets_support(client, fake_gitea):
data = client.get("/api/issues/Projets/demo/1").json()
assert data["issue"]["attachments_supported"] is True
assert data["issue"]["assets"] == []
def test_asset_roundtrip(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/assets",
data={"csrf_token": token},
files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")},
)
assert r.status_code == 201
asset = r.json()["asset"]
assert asset["name"] == "notes.txt"
assert asset["size"] == len(b"bonjour kanbante")
# Visible dans le détail.
data = client.get("/api/issues/Projets/demo/1").json()
assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"]
# Téléchargement proxifié : contenu + disposition.
dl = client.get(
f"/api/issues/Projets/demo/1/assets/{asset['id']}/download"
)
assert dl.status_code == 200
assert dl.content == b"bonjour kanbante"
assert "notes.txt" in dl.headers["content-disposition"]
# Suppression.
token = csrf_from(client)
r = client.post(
f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete",
data={"csrf_token": token},
)
assert r.status_code == 200
data = client.get("/api/issues/Projets/demo/1").json()
assert data["issue"]["assets"] == []
def test_asset_upload_rejects_empty_and_huge(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/assets",
data={"csrf_token": token},
files={"attachment": ("vide.txt", b"", "text/plain")},
)
assert r.status_code == 400
def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
page = client.get("/board/Projets/demo")
assert page.status_code == 200
assert 'id="detail-edit-btn"' in page.text
assert 'id="detail-edit-form"' in page.text
assert 'id="detail-assets"' in page.text
assert 'id="asset-form"' in page.text
# Création avec description dépliable (0.31.1 : <details>
# natif plutôt qu'un bouton piloté en JS).
assert 'id="new-issue-body"' in page.text
assert 'id="new-issue-desc"' in page.text
assert 'class="composer"' in page.text
async def test_gitea_download_follows_browser_download_url():
"""Gitea renvoie la fiche JSON de l'asset, pas le binaire :
le téléchargement doit suivre browser_download_url (réel
1.27.3, attrapé au smoke de la 0.31.0)."""
import httpx
from app.gitea_client import GiteaClient
def handler(request):
url = str(request.url)
if url.endswith("/assets/42"):
return httpx.Response(200, json={
"id": 42, "name": "preuve.txt",
"browser_download_url": "https://gitea.test/attachments/uuid-1",
})
if url.endswith("/attachments/uuid-1"):
assert request.headers.get("authorization", "").startswith("token ")
return httpx.Response(
200, content=b"contenu reel",
headers={"content-type": "text/plain"},
)
return httpx.Response(404)
client = GiteaClient(
base_url="https://gitea.test", token="jeton" + "-factice",
cache_ttl=0, transport=httpx.MockTransport(handler),
)
content, content_type = await client.download_issue_asset(
"Projets", "demo", 1, 42
)
assert content == b"contenu reel"
assert content_type == "text/plain"
+39
View File
@@ -0,0 +1,39 @@
"""Palier 22 (0.33.0): barre de filtres de la page Mes issues —
facettes serveur, lignes annotées, moteur client chargé."""
def test_my_page_has_filter_bar_and_facets(client):
r = client.get("/my")
assert r.status_code == 200
# La barre et ses contrôles.
assert 'id="my-filters"' in r.text
assert 'id="my-search"' in r.text
assert 'id="my-repo"' in r.text
assert 'id="my-label"' in r.text
assert 'id="my-sort"' in r.text
assert 'id="my-col-chips"' in r.text
assert 'id="my-count"' in r.text
assert 'id="my-reset"' in r.text
# Facettes avec compteurs (démo : 1 issue assignée,
# étiquette « bug »).
assert "Projets/demo (1)" in r.text
assert "bug (1)" in r.text
# Le moteur de filtrage est chargé, versionné.
assert "/static/js/my.js?v=" in r.text
def test_my_rows_carry_filter_attributes(client):
r = client.get("/my")
# Attributs data-* consommés par le moteur client.
assert 'data-column="backlog"' in r.text
assert 'data-search="#1 Tâche un Projets/demo bug"' in r.text
assert 'data-updated="2026-09-02T09:00:00Z"' in r.text
assert 'data-repo="Projets/demo"' in r.text
# Chips de colonnes présentes, avec compteurs.
assert 'data-col="backlog"' in r.text
assert 'data-col=""' in r.text # « Toutes les colonnes »
def test_my_empty_state_present_but_hidden(client):
r = client.get("/my")
assert 'id="my-empty" hidden' in r.text
+72
View File
@@ -0,0 +1,72 @@
"""Palier 23 (0.34.0, issue #24): vue « En vol partout » —
l'encours tous projets, regroupé par colonne, plus vieilles
cartes d'abord."""
from datetime import datetime, timedelta, timezone
from app import models
def _days_ago(days: int) -> str:
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
"%Y-%m-%d %H:%M:%S"
)
def test_inflight_groups_by_column_with_project(client):
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/inflight")
assert r.status_code == 200
assert "2 cartes en vol" in r.text
assert "Tâche focus" in r.text
assert "Tâche un" in r.text
# Le projet est indiqué sur la carte.
assert "Projets/demo" in r.text
# Groupes par colonne, pas par projet.
assert "🚧 En cours" in r.text
assert "🔍 Révision" in r.text
# L'âge dans la colonne est affiché.
assert "dans la colonne" in r.text
def test_inflight_excludes_backlog_and_todo(client):
models.move_issue("Projets/demo", 5, "todo")
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
assert "Rien en vol" in r.text
def test_inflight_oldest_first(client):
# Deux cartes en cours : entrée il y a 10 j et il y a 2 j.
models.move_issue("Projets/demo", 5, "in_progress")
models.move_issue("Projets/demo", 1, "in_progress")
models.log_event(
"Projets/demo", 5, "moved", to_column="in_progress",
created_at=_days_ago(10),
)
models.log_event(
"Projets/demo", 1, "moved", to_column="in_progress",
created_at=_days_ago(2),
)
r = client.get("/inflight")
assert r.status_code == 200
assert r.text.index("Tâche focus") < r.text.index("Tâche un")
def test_inflight_closed_issue_disappears(client, fake_gitea):
models.move_issue("Projets/demo", 5, "in_progress")
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
issue["state"] = "closed"
r = client.get("/inflight")
assert r.status_code == 200
assert "Tâche focus" not in r.text
def test_inflight_nav_and_page_chrome(client):
r = client.get("/inflight")
assert 'href="/inflight"' in r.text # menu latéral Pilotage
assert "En vol partout" in r.text
+77
View File
@@ -0,0 +1,77 @@
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
from tests.conftest import csrf_from
def test_milestone_create_via_api(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Version 2.0",
"description": "Le gros morceau",
"due_on": "2027-01-31",
"csrf_token": token,
},
)
assert r.status_code == 201
payload = r.json()
assert payload["ok"] is True
assert payload["milestone"]["title"] == "Version 2.0"
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
titles = [
m["title"] for m in fake_gitea._milestones["Projets/demo"]
]
assert "Version 2.0" in titles
def test_milestone_create_rejects_bad_date(client, fake_gitea):
token = csrf_from(client)
avant = len(fake_gitea._milestones["Projets/demo"])
r = client.post(
"/api/repos/Projets/demo/milestones",
data={
"title": "Jalon invalide",
"due_on": "31/01/2027",
"csrf_token": token,
},
)
assert r.status_code == 400
# Rien d'écrit côté forge.
assert len(fake_gitea._milestones["Projets/demo"]) == avant
def test_milestone_update_title_and_clear_due(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/update",
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
)
assert r.status_code == 200
sprint = fake_gitea._milestones["Projets/demo"][0]
assert sprint["title"] == "Sprint 1 — prolongé"
assert sprint["due_on"] is None # échéance retirée
def test_milestone_close(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/repos/Projets/demo/milestones/1/close",
data={"csrf_token": token},
)
assert r.status_code == 200
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
page = client.get("/project/Projets/demo")
assert "Version" in page.text # la page répond toujours
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
def test_project_page_has_milestone_management(client):
page = client.get("/project/Projets/demo")
assert page.status_code == 200
assert "Nouveau jalon" in page.text
assert "Modifier / fermer" in page.text
assert 'name="due_on"' in page.text
assert "/static/js/project.js?v=" in page.text
+36
View File
@@ -0,0 +1,36 @@
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
les projets (section Épinglés incluse) s'ordonnent par le nombre
de cartes dans la colonne de validation."""
from app import models
def test_dashboard_sort_offers_review_mode(client):
r = client.get("/")
assert r.status_code == 200
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
def test_dashboard_cards_carry_review_count(client):
# Aucune carte en révision au départ.
r = client.get("/")
assert 'data-review="0"' in r.text
# Une carte passe en Révision : le compteur de SA carte monte.
models.move_issue("Projets/demo", 5, "review")
models.move_issue("Projets/demo", 1, "review")
r = client.get("/")
assert 'data-review="2"' in r.text
def test_dashboard_js_knows_review_mode(client):
# Le moteur de tri partagé est servi tel quel : le mode
# 'review' doit y figurer (tri décroissant sur data-review).
r = client.get("/static/js/dashboard.js")
assert r.status_code == 200
assert "mode === 'review'" in r.text
assert "data-review" in r.text
# Le sens : les modes numeriques trient en ka - kb (cles
# niees = plus grand compteur d\'abord). Sans cette
# branche, le comparateur generique inversait le tri :
# bug attrape en navigateur reel sur le tri historique.
assert "return ka - kb" in r.text
+5 -2
View File
@@ -121,7 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
assert names == [ 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 ────────────────────────