Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d419fe3086 | ||
|
|
65c467bc90 | ||
|
|
4a97e0d0dc | ||
|
|
7852d860fe | ||
|
|
d4cf8d1275 | ||
|
|
28322c12cf | ||
|
|
3964323abd |
@@ -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=
|
||||||
+248
@@ -1,5 +1,253 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.32.2 — 2026-10-25
|
||||||
|
|
||||||
|
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
|
||||||
|
|
||||||
|
Sur kanbante-dev, le mode console du panneau était une page morte
|
||||||
|
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
|
||||||
|
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
|
||||||
|
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
|
||||||
|
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
|
||||||
|
en `-dev`, le panneau recevait donc le défaut prod
|
||||||
|
`https://agent.dracodev.net` : iframe de console pointant un agent
|
||||||
|
prod pas encore installé, donc injoignable. Et aucune résolution
|
||||||
|
ne tenait compte de la requête : sans variable d'environnement,
|
||||||
|
un hôte public en `-dev` retombait entièrement sur la prod.
|
||||||
|
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
|
||||||
|
énoncée nulle part.
|
||||||
|
|
||||||
|
- **Résolution par requête** (`resoudre_urls_draco` dans
|
||||||
|
`app/config.py`, consommée par `render()` dans `app/main.py`)
|
||||||
|
pour tout ce qui est rendu à une requête — liens de la barre
|
||||||
|
(`draco_urls`), source de l'iframe du panneau
|
||||||
|
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
|
||||||
|
l'ordre : 1) la variable explicite de l'installation,
|
||||||
|
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
|
||||||
|
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
|
||||||
|
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
|
||||||
|
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
|
||||||
|
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
|
||||||
|
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
|
||||||
|
toutes les sœurs et à l'agent — jamais de suffixe inventé
|
||||||
|
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
|
||||||
|
suivante ; 3) la dérivation par les adresses de l'agent connues
|
||||||
|
en configuration, **élargie** : URL d'événements **ou** base du
|
||||||
|
service, selon ce que l'installation déclare ; 4) le défaut
|
||||||
|
officiel.
|
||||||
|
- **Base de l'agent dérivée** (contextes sans requête aussi) :
|
||||||
|
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
|
||||||
|
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
|
||||||
|
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
|
||||||
|
lieu de rester au défaut prod.
|
||||||
|
- **CSP nouvelle** : chaque page rendue énonce
|
||||||
|
`Content-Security-Policy: frame-src 'self' <origine agent>` —
|
||||||
|
exactement l'origine résolue pour cette même réponse, celle de
|
||||||
|
l'iframe de la console (seule iframe de l'application).
|
||||||
|
- Les contextes sans requête gardent la résolution par
|
||||||
|
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
|
||||||
|
n'en a pas par conception ; périmètre limité à ses liens et à
|
||||||
|
son panneau.
|
||||||
|
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
|
||||||
|
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
|
||||||
|
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
|
||||||
|
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
|
||||||
|
journal et dérivation élargie à la base du service ; contextes
|
||||||
|
sans requête inchangés. Suite complète : 278 tests réussis.
|
||||||
|
- **Déploiement** : cette version part avec le prochain
|
||||||
|
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
|
||||||
|
preuve en direct **en attente du redéploiement Docker de
|
||||||
|
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
|
||||||
|
|
||||||
|
## 0.32.1 — 2026-10-25
|
||||||
|
|
||||||
|
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
|
||||||
|
|
||||||
|
Sur la dev, le menu inter-apps pointait vers les noms **prod**
|
||||||
|
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
|
||||||
|
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
|
||||||
|
défaut officiel faute de variable explicite — il aurait fallu
|
||||||
|
déclarer quatre variables de plus dans le `.env` dev.
|
||||||
|
|
||||||
|
- **Résolution des URL sœurs** dans `app/config.py` (les
|
||||||
|
gabarits et le JS de la barre consomment `draco_urls`
|
||||||
|
inchangé), dans l'ordre : 1) la variable explicite —
|
||||||
|
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
|
||||||
|
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
|
||||||
|
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
|
||||||
|
d'environnement** depuis l'origine de l'agent telle que
|
||||||
|
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
|
||||||
|
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
|
||||||
|
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
|
||||||
|
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
|
||||||
|
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
|
||||||
|
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
|
||||||
|
deviné ; 3) le défaut officiel (prod), comme avant.
|
||||||
|
- L'URL Gitea ne change pas.
|
||||||
|
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
|
||||||
|
quatre URL sœurs servies sont en `-dev` ; une URL explicite
|
||||||
|
gagne sur la dérivation ; hôte d'agent hors motif et aucun
|
||||||
|
environnement posé restent aux défauts officiels
|
||||||
|
(non-régression).
|
||||||
|
|
||||||
|
|
||||||
|
## 0.32.0 — 2026-10-25
|
||||||
|
|
||||||
|
### Refactor — noms publics dracodev par configuration (dev/prod)
|
||||||
|
|
||||||
|
Aucun nom public dracodev n'est plus codé en dur dans le code
|
||||||
|
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
|
||||||
|
dont les défauts sont les noms **officiels** (prod). L'environnement
|
||||||
|
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
|
||||||
|
|
||||||
|
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
|
||||||
|
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
|
||||||
|
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
|
||||||
|
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
|
||||||
|
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
|
||||||
|
`AGENT_URL` quand il n'est pas posé explicitement (l'override
|
||||||
|
compose existant garde la priorité).
|
||||||
|
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
|
||||||
|
viennent de `draco_urls` injecté par `render()` ; le panneau
|
||||||
|
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
|
||||||
|
injecté avant `draco-panneau.js` (chargé avec le cache-busting
|
||||||
|
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
|
||||||
|
aucun nom : sans injection, il se déclare non configuré.
|
||||||
|
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
|
||||||
|
est construit depuis la configuration (`sso_authorize_url`).
|
||||||
|
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
|
||||||
|
document d'installation dev affirmant « pas de SSO par conception »
|
||||||
|
pour Kanbante — comportement existant conservé tel quel, juste
|
||||||
|
rendu configurable ; la contradiction documentaire est signalée,
|
||||||
|
pas tranchée ici.
|
||||||
|
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
|
||||||
|
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
|
||||||
|
- Tests barre/SSO mis en cohérence via la configuration (défauts
|
||||||
|
officiels + variantes `-dev`), `.env.example` créé, README et
|
||||||
|
`docker-compose.yml` documentent chaque variable touchée.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.31.1 — 2026-10-23
|
||||||
|
|
||||||
|
### Corrigé — compositeur de création refait
|
||||||
|
|
||||||
|
Retour de Bruno sur la 0.31.0 : la barre de création
|
||||||
|
n'était ni élégante ni professionnelle, et le bouton
|
||||||
|
« + Description » pouvait rester sans effet (écouteur
|
||||||
|
JavaScript non enregistré selon l'état du cache ou des
|
||||||
|
scripts de la page).
|
||||||
|
|
||||||
|
- **Compositeur en carte** : en-tête « Nouvelle issue »
|
||||||
|
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
|
||||||
|
nom personnalisé de l'espace respecté), champ titre
|
||||||
|
pleine largeur avec anneau de focus, bouton primaire
|
||||||
|
« Créer l'issue ».
|
||||||
|
- **Description en `<details>` natif** : « Ajouter une
|
||||||
|
description ▾ » déplie la zone texte sans aucun
|
||||||
|
JavaScript — impossible à casser par un script en
|
||||||
|
erreur ou un fichier en cache. Aide intégrée (Markdown,
|
||||||
|
Ctrl+Entrée pour créer, actif aussi depuis le titre).
|
||||||
|
- **Assets du board versionnés** (`?v=<version>` sur le
|
||||||
|
CSS et board.js du board) : un redéploiement invalide
|
||||||
|
le cache navigateur au lieu de servir un mélange
|
||||||
|
d'anciennes et nouvelles pièces.
|
||||||
|
- Vérifié en navigateur réel (Chromium) sur le vrai
|
||||||
|
board : captures fermé/ouvert contrôlées visuellement,
|
||||||
|
clic sur le libellé, zone visible et éditable.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.31.0 — 2026-10-23
|
||||||
|
|
||||||
|
### Ajouté — description éditable, pièces jointes, clic fiable
|
||||||
|
|
||||||
|
Retours de Bruno : impossible d'ajouter une description
|
||||||
|
après création, aucun moyen de joindre des fichiers comme
|
||||||
|
dans Gitea, et clic sur les cartes capricieux.
|
||||||
|
|
||||||
|
- **Titre et description éditables** dans le tiroir de
|
||||||
|
détail (bouton « ✏️ Modifier le titre et la description ») :
|
||||||
|
l'enregistrement écrit directement dans l'issue de la
|
||||||
|
forge (PATCH), le journal consigne « edited ✏️ », et la
|
||||||
|
carte du board reflète le nouveau titre sans recharger.
|
||||||
|
- **Description dès la création** : le formulaire « Nouvelle
|
||||||
|
issue » gagne un bouton « + Description » qui déplie un
|
||||||
|
champ texte transmis à la création.
|
||||||
|
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
|
||||||
|
liste des fichiers (nom, taille, date), **ajout** par le
|
||||||
|
sélecteur de fichier (25 Mio max), **téléchargement**
|
||||||
|
proxifié par Kanbante (le jeton ne quitte jamais le
|
||||||
|
serveur ; suit le `browser_download_url` de Gitea, dont
|
||||||
|
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
|
||||||
|
au smoke réel), **suppression** avec confirmation. Sur
|
||||||
|
GitHub, l'API ne gère pas les pièces jointes d'issues :
|
||||||
|
l'écran l'explique et renvoie à l'interface web.
|
||||||
|
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
|
||||||
|
passe en **délégation d'événement** — avant, l'écouteur
|
||||||
|
était posé carte par carte au chargement ; le premier
|
||||||
|
rafraîchissement temps réel remplaçait les fragments et
|
||||||
|
les cartes devenaient muettes. Le seuil anti-glisser
|
||||||
|
(6 px) et l'exclusion liens/boutons sont conservés, et
|
||||||
|
les cartes affichent un curseur pointeur.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.30.0 — 2026-10-23
|
||||||
|
|
||||||
|
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
|
||||||
|
|
||||||
|
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
|
||||||
|
des colonnes (Backlog, À faire, En cours, …) et configurer le
|
||||||
|
mapping label/colonne depuis l'interface web.
|
||||||
|
|
||||||
|
- **Colonnes par espace** (Administration → Colonnes, ou le
|
||||||
|
bouton 🧱 Colonnes sur un board) : ajouter une colonne
|
||||||
|
(insérée avant Terminé), la renommer (le nom affiché est
|
||||||
|
libre, émojis compris), changer sa couleur, la monter ou la
|
||||||
|
descendre, la supprimer — ses cartes sont alors déplacées
|
||||||
|
vers une colonne de destination choisie. Réinitialisation
|
||||||
|
aux six colonnes historiques en un clic. Chaque espace a SES
|
||||||
|
colonnes.
|
||||||
|
- **Clés immuables** : une colonne garde sa clé interne
|
||||||
|
(`en_developpement`) quoi qu'on renomme — les cartes, les
|
||||||
|
limites WIP et les règles y restent rattachés. Backlog et
|
||||||
|
Terminé sont des colonnes système (renommables, non
|
||||||
|
supprimables) : défaut des cartes neuves, et issues fermées.
|
||||||
|
- **Mapping étiquette ↔ colonne configurable** : chaque
|
||||||
|
colonne de flux a son **étiquette mappée** (défaut
|
||||||
|
`kanban:<clé>`) posée sur l'issue à chaque déplacement
|
||||||
|
(miroir), plus des **alias** reconnus à l'adoption — par
|
||||||
|
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
|
||||||
|
alias de la colonne À faire fait adopter cette colonne par
|
||||||
|
le board. Adoption : exactement une étiquette reconnue,
|
||||||
|
comme avant.
|
||||||
|
- **Focus / Wallboard** : chaque colonne déclare si elle y
|
||||||
|
figure (case « Dans Focus / Wallboard ») ; les nouvelles
|
||||||
|
colonnes y sont par défaut.
|
||||||
|
- Sans personnalisation, tout est strictement identique à
|
||||||
|
avant (définitions semées paresseusement depuis les valeurs
|
||||||
|
historiques). Le triage au clavier garde ses touches
|
||||||
|
historiques (1 = À faire, 2 = En cours, …).
|
||||||
|
|
||||||
|
|
||||||
|
## 0.29.0 — 2026-10-23
|
||||||
|
|
||||||
|
### Modifié — panneau Spark V3 : la console en surimpression pleine page
|
||||||
|
|
||||||
|
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
|
||||||
|
plus en nouvel onglet : la console web du service d'agents
|
||||||
|
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
|
||||||
|
page**, en surimpression qui recouvre tout l'écran avec la même
|
||||||
|
animation d'ouverture que la barre latérale (panneau vendangé
|
||||||
|
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
|
||||||
|
styles de la surimpression sont injectés par le panneau lui-même).
|
||||||
|
La barre de la surimpression offre les deux sorties : « Mode
|
||||||
|
sidebar » referme la console et rouvre l'agent en barre latérale,
|
||||||
|
« Fermer l'agent » ferme la surimpression et le panneau. La
|
||||||
|
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
|
||||||
|
passent comme avant à la console. Côté service d'agents : la
|
||||||
|
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
|
||||||
|
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
|
||||||
|
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
|
||||||
|
|
||||||
## 0.28.1 — 2026-10-22
|
## 0.28.1 — 2026-10-22
|
||||||
|
|
||||||
### Ajouté — panneau Spark : bouton « Console ↗ »
|
### Ajouté — panneau Spark : bouton « Console ↗ »
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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.28.1"
|
APP_VERSION = "0.32.2"
|
||||||
|
|
||||||
|
|
||||||
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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
@@ -503,6 +504,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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+613
-74
File diff suppressed because it is too large
Load Diff
+137
-7
@@ -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
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -38,6 +38,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"),
|
||||||
]),
|
]),
|
||||||
] %}
|
] %}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<title>Colonnes — Administration — Kanbante</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>🧱 Colonnes du board</h1>
|
||||||
|
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
|
||||||
|
</div>
|
||||||
|
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
|
||||||
|
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
|
||||||
|
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>Comment marche le mapping</h2>
|
||||||
|
<p class="notes-hint">
|
||||||
|
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
|
||||||
|
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
|
||||||
|
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
|
||||||
|
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for c in columns %}
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>
|
||||||
|
<span class="ws-dot" style="background: {{ c.color }}"></span>
|
||||||
|
{{ c.name }}
|
||||||
|
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
|
||||||
|
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
|
||||||
|
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
|
||||||
|
</h2>
|
||||||
|
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
|
||||||
|
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
|
||||||
|
{% if c.kind == 'flow' %}
|
||||||
|
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
|
||||||
|
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
|
||||||
|
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||||
|
</form>
|
||||||
|
<p class="admin-actions">
|
||||||
|
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
|
||||||
|
</form>
|
||||||
|
{% if c.kind == 'flow' %}
|
||||||
|
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>Déplacer ses cartes vers
|
||||||
|
<select name="target">
|
||||||
|
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>➕ Nouvelle colonne</h2>
|
||||||
|
<form method="post" action="/admin/columns" class="admin-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
|
||||||
|
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
|
||||||
|
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
|
||||||
|
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
|
||||||
|
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="briefing-card">
|
||||||
|
<h2>↩️ Réinitialiser</h2>
|
||||||
|
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
|
||||||
|
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,11 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="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">
|
||||||
|
|||||||
@@ -146,7 +146,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] }}">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.28.1"
|
version = "0.32.2"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1683,3 +1683,34 @@ 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; }
|
||||||
|
|||||||
+152
-5
@@ -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';
|
||||||
|
|||||||
+136
-12
@@ -18,15 +18,25 @@
|
|||||||
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||||
* (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 un nouvel onglet, sur la
|
* du même service (adresse injectée par l'hôte) DANS la page, en surimpression
|
||||||
* discussion courante (?disc=) et avec l'app d'origine (?app=),
|
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||||
* déduite du sous-domaine de l'hôte.
|
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||||
|
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||||
|
* du premier chargement — l'iframe est créé paresseusement et
|
||||||
|
* conservé, la console garde son état entre ouvertures. La barre
|
||||||
|
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||||
|
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||||
|
* surimpression et panneau).
|
||||||
* États honnêtes : service injoignable / session non reconnue / LLM non
|
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||||
* 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 = "";
|
||||||
@@ -48,23 +58,124 @@
|
|||||||
catch (e) { return null; }
|
catch (e) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Console web (même service, nouvel onglet) ─────────────── */
|
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||||
function appHote() {
|
function appHote() {
|
||||||
var h = (window.location.hostname || "").split(".")[0];
|
var h = (window.location.hostname || "").split(".")[0];
|
||||||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||||
kanbante: "kanbante", og: "obsigate" };
|
kanbante: "kanbante", og: "obsigate" };
|
||||||
return correspondance[h] || null;
|
return correspondance[h] || null;
|
||||||
}
|
}
|
||||||
function ouvrirConsole() {
|
function urlConsole() {
|
||||||
var params = [];
|
var params = [];
|
||||||
var id = convId || convStockee();
|
var id = convId || convStockee();
|
||||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
var app = appHote();
|
var app = appHote();
|
||||||
if (app) params.push("app=" + encodeURIComponent(app));
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
window.open(API + "/" + (params.length ? "?" + params.join("&") : ""),
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
"_blank", "noopener");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||||
|
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||||
|
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||||
|
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||||
|
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||||
|
* conservé : rouvrir la console ne le recharge pas. */
|
||||||
|
var surEl = null, surCadre = null, surOuverte = false;
|
||||||
|
var STYLES_CONSOLE =
|
||||||
|
".draco-console-sur{" +
|
||||||
|
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||||
|
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||||
|
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||||
|
"background:var(--p-fond);color:var(--p-texte);" +
|
||||||
|
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||||
|
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||||
|
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||||
|
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||||
|
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||||
|
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||||
|
".draco-console-sur[hidden]{display:none}" +
|
||||||
|
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||||
|
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||||
|
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||||
|
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||||
|
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||||
|
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||||
|
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||||
|
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||||
|
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||||
|
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||||
|
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||||
|
|
||||||
|
function assurerSurimpression() {
|
||||||
|
if (surEl) return;
|
||||||
|
if (!document.getElementById("draco-console-styles")) {
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.id = "draco-console-styles";
|
||||||
|
st.textContent = STYLES_CONSOLE;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
surEl = document.createElement("div");
|
||||||
|
surEl.className = "draco-console-sur";
|
||||||
|
surEl.setAttribute("aria-label", "Console Spark");
|
||||||
|
surEl.hidden = true;
|
||||||
|
surEl.innerHTML =
|
||||||
|
'<div class="draco-console-barre">' +
|
||||||
|
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||||
|
' <span class="dc-actions">' +
|
||||||
|
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||||
|
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||||
|
' </span></div>' +
|
||||||
|
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||||
|
document.body.appendChild(surEl);
|
||||||
|
surCadre = surEl.querySelector("iframe");
|
||||||
|
surEl.querySelector('[data-dc="sidebar"]')
|
||||||
|
.addEventListener("click", modeSidebarConsole);
|
||||||
|
surEl.querySelector('[data-dc="fermer"]')
|
||||||
|
.addEventListener("click", fermerAgentConsole);
|
||||||
|
appliquerTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function largeurPanneau() {
|
||||||
|
if (root) {
|
||||||
|
var w = root.getBoundingClientRect().width;
|
||||||
|
if (w > 0) return Math.round(w);
|
||||||
|
}
|
||||||
|
return 388;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrirConsole() {
|
||||||
|
assurerSurimpression();
|
||||||
|
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||||
|
if (surOuverte) return;
|
||||||
|
surOuverte = true;
|
||||||
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
|
* extension à toute la page ; le double rAF laisse le temps de
|
||||||
|
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
surEl.hidden = false;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrecirConsole() {
|
||||||
|
if (!surEl || !surOuverte) return;
|
||||||
|
surOuverte = false;
|
||||||
|
var fini = false;
|
||||||
|
function terminer() {
|
||||||
|
if (fini) return;
|
||||||
|
fini = true;
|
||||||
|
surEl.removeEventListener("transitionend", surFin);
|
||||||
|
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||||
|
}
|
||||||
|
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||||
|
surEl.addEventListener("transitionend", surFin);
|
||||||
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
}
|
||||||
|
function modeSidebarConsole() { retrecirConsole(); }
|
||||||
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
function themeHote() {
|
function themeHote() {
|
||||||
var t = document.documentElement.getAttribute("data-theme");
|
var t = document.documentElement.getAttribute("data-theme");
|
||||||
@@ -78,6 +189,7 @@
|
|||||||
}
|
}
|
||||||
function appliquerTheme() {
|
function appliquerTheme() {
|
||||||
if (root) root.setAttribute("data-draco-theme", themeHote());
|
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||||
|
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||||
}
|
}
|
||||||
function surveillerTheme() {
|
function surveillerTheme() {
|
||||||
try {
|
try {
|
||||||
@@ -143,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) dans un nouvel onglet, 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">' +
|
||||||
@@ -178,7 +290,13 @@
|
|||||||
appliquerLargeur();
|
appliquerLargeur();
|
||||||
window.addEventListener("resize", appliquerLargeur);
|
window.addEventListener("resize", appliquerLargeur);
|
||||||
document.addEventListener("keydown", function (ev) {
|
document.addEventListener("keydown", function (ev) {
|
||||||
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
if (ev.key !== "Escape") return;
|
||||||
|
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||||
|
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||||
|
* console (autre origine), l'événement ne remonte pas
|
||||||
|
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||||
|
if (surOuverte) { modeSidebarConsole(); return; }
|
||||||
|
if (root.classList.contains("open")) fermer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,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) {
|
||||||
@@ -232,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é.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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), []))
|
||||||
|
|
||||||
|
|||||||
+359
-1
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Palier 20 (0.30.0): colonnes personnalisables par
|
||||||
|
espace et mapping étiquette ↔ colonne configurable."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import board_columns as bcols
|
||||||
|
from app import models
|
||||||
|
from tests.conftest import csrf_from
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(tmp_path, monkeypatch, fake_gitea):
|
||||||
|
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||||
|
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||||
|
with TestClient(main.app) as client:
|
||||||
|
client.get("/login")
|
||||||
|
token = client.cookies.get("kanbante_csrf") or ""
|
||||||
|
client.post(
|
||||||
|
"/login",
|
||||||
|
data={
|
||||||
|
"username": "bruno",
|
||||||
|
"password": "motdepasse",
|
||||||
|
"next": "/",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws_cols():
|
||||||
|
"""Un espace dédié rattaché à la connexion héritée."""
|
||||||
|
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
|
||||||
|
models.set_workspace_connections(ws_id, [1])
|
||||||
|
bcols.invalidate(ws_id)
|
||||||
|
yield ws_id
|
||||||
|
models.clear_board_columns(ws_id)
|
||||||
|
bcols.invalidate(ws_id)
|
||||||
|
models.delete_workspace(ws_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(number, title, labels=None):
|
||||||
|
return {
|
||||||
|
"number": number,
|
||||||
|
"title": title,
|
||||||
|
"body": "",
|
||||||
|
"state": "open",
|
||||||
|
"labels": labels or [],
|
||||||
|
"assignee": None,
|
||||||
|
"assignees": [],
|
||||||
|
"milestone": None,
|
||||||
|
"due_date": None,
|
||||||
|
"created_at": "2026-09-01T09:00:00Z",
|
||||||
|
"updated_at": "2026-09-02T09:00:00Z",
|
||||||
|
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||||
|
"pull_request": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Défauts et service ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_defaults_match_historical_columns(ws_cols):
|
||||||
|
columns = bcols.get_columns(ws_cols)
|
||||||
|
assert [c["key"] for c in columns] == [
|
||||||
|
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||||
|
]
|
||||||
|
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
|
||||||
|
assert bcols.label_for(ws_cols, "review") == "kanban:review"
|
||||||
|
assert bcols.label_for(ws_cols, "backlog") is None
|
||||||
|
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
|
||||||
|
assert bcols.wall_keys(ws_cols) == [
|
||||||
|
"todo", "in_progress", "qa", "review",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_column_at_model_level(ws_cols):
|
||||||
|
columns = bcols.get_columns(ws_cols)
|
||||||
|
models.upsert_board_column(
|
||||||
|
ws_cols,
|
||||||
|
{
|
||||||
|
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
|
||||||
|
"position": 4, "label": "etape:dev", "aliases": "",
|
||||||
|
"kind": "flow", "in_focus": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
bcols.invalidate(ws_cols)
|
||||||
|
assert "dev" in models.column_keys(ws_cols)
|
||||||
|
models.move_issue(
|
||||||
|
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
|
||||||
|
)
|
||||||
|
state = models.get_board_state(
|
||||||
|
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||||
|
)
|
||||||
|
assert [c["issue_number"] for c in state["dev"]] == [5]
|
||||||
|
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
|
||||||
|
assert "dev" in bcols.focus_keys(ws_cols)
|
||||||
|
# Un autre espace n'est pas affecté.
|
||||||
|
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
|
||||||
|
try:
|
||||||
|
assert "dev" not in models.column_keys(other)
|
||||||
|
finally:
|
||||||
|
models.delete_workspace(other)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Panneau d'administration ──────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_columns_crud(auth_client, ws_cols):
|
||||||
|
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||||
|
page = auth_client.get("/admin/columns")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "Colonnes du board" in page.text
|
||||||
|
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
r = auth_client.post(
|
||||||
|
"/admin/columns",
|
||||||
|
data={
|
||||||
|
"name": "🛠️ En développement",
|
||||||
|
"color": "#224466",
|
||||||
|
"label": "etape:dev",
|
||||||
|
"aliases": "en-dev",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
keys = bcols.keys(ws_cols)
|
||||||
|
assert "en_developpement" in keys
|
||||||
|
# Insérée avant « done ».
|
||||||
|
assert keys.index("en_developpement") == keys.index("done") - 1
|
||||||
|
|
||||||
|
# Renommage + mapping via le formulaire de la colonne.
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns/en_developpement",
|
||||||
|
data={
|
||||||
|
"name": "🚀 Dev",
|
||||||
|
"color": "#224466",
|
||||||
|
"label": "etape:dev",
|
||||||
|
"aliases": "en-dev, dev",
|
||||||
|
"in_focus": "1",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
|
||||||
|
lookup = bcols.label_lookup(ws_cols)
|
||||||
|
assert lookup["etape:dev"] == "en_developpement"
|
||||||
|
assert lookup["en-dev"] == "en_developpement"
|
||||||
|
|
||||||
|
# Le board affiche la nouvelle colonne.
|
||||||
|
board = auth_client.get("/board/Projets/demo")
|
||||||
|
assert 'data-column="en_developpement"' in board.text
|
||||||
|
assert "🚀 Dev" in board.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
|
||||||
|
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns",
|
||||||
|
data={
|
||||||
|
"name": "Attente client", "color": "#445566", "label": "",
|
||||||
|
"aliases": "", "csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert "attente_client" in bcols.keys(ws_cols)
|
||||||
|
models.move_issue(
|
||||||
|
"Projets/demo", 5, "attente_client",
|
||||||
|
workspace_id=ws_cols, connection_id=1,
|
||||||
|
)
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
r = auth_client.post(
|
||||||
|
"/admin/columns/attente_client/delete",
|
||||||
|
data={"target": "backlog", "csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 303
|
||||||
|
assert "attente_client" not in bcols.keys(ws_cols)
|
||||||
|
state = models.get_board_state(
|
||||||
|
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||||
|
)
|
||||||
|
assert any(c["issue_number"] == 5 for c in state["backlog"])
|
||||||
|
# Les colonnes système ne se suppriment pas.
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns/backlog/delete",
|
||||||
|
data={"target": "todo", "csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert "backlog" in bcols.keys(ws_cols)
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_columns_reset(auth_client, ws_cols):
|
||||||
|
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns",
|
||||||
|
data={
|
||||||
|
"name": "Temporaire", "color": "#445566", "label": "",
|
||||||
|
"aliases": "", "csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert "temporaire" in bcols.keys(ws_cols)
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns/reset",
|
||||||
|
data={"csrf_token": token},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert bcols.keys(ws_cols) == [
|
||||||
|
"backlog", "todo", "in_progress", "qa", "review", "done",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mapping : adoption par alias et miroir personnalisé ──
|
||||||
|
|
||||||
|
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
|
||||||
|
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
|
||||||
|
colonne todo, fait adopter À faire par le board."""
|
||||||
|
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns/todo",
|
||||||
|
data={
|
||||||
|
"name": "📋 À faire", "color": "#3b82f6",
|
||||||
|
"label": "kanban:todo", "aliases": "kanban_a_faire",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
fake_gitea.issues["Projets/demo"].append(
|
||||||
|
_issue(42, "Carte Flowdeck", labels=[
|
||||||
|
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
payload = auth_client.get("/api/board/Projets/demo").json()
|
||||||
|
assert "42" in payload["columns"]["todo"]["html"]
|
||||||
|
state = models.get_board_state(
|
||||||
|
"Projets/demo", workspace_id=ws_cols, connection_id=1
|
||||||
|
)
|
||||||
|
assert any(c["issue_number"] == 42 for c in state["todo"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
|
||||||
|
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
|
||||||
|
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
auth_client.post(
|
||||||
|
"/admin/columns",
|
||||||
|
data={
|
||||||
|
"name": "En développement", "color": "#224466",
|
||||||
|
"label": "etape:dev", "aliases": "", "csrf_token": token,
|
||||||
|
},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
token = csrf_from(auth_client)
|
||||||
|
r = auth_client.post(
|
||||||
|
"/api/move",
|
||||||
|
data={
|
||||||
|
"repo_full_name": "Projets/demo",
|
||||||
|
"issue_number": "5",
|
||||||
|
"to_column": "en_developpement",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
issue = next(
|
||||||
|
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
|
||||||
|
)
|
||||||
|
names = [l["name"] for l in issue.get("labels") or []]
|
||||||
|
assert "etape:dev" in names
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Palier 21 (0.31.0): description éditable, pièces jointes
|
||||||
|
d'issues, clic carte robuste (délégation côté JS)."""
|
||||||
|
|
||||||
|
from tests.conftest import csrf_from
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_update_title_and_body(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/issues/Projets/demo/1/update",
|
||||||
|
data={
|
||||||
|
"title": "Tâche un — précisée",
|
||||||
|
"body": "## Contexte\n\nLa description qui manquait.",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
payload = r.json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["issue"]["body"].startswith("## Contexte")
|
||||||
|
issue = next(
|
||||||
|
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1
|
||||||
|
)
|
||||||
|
assert issue["title"] == "Tâche un — précisée"
|
||||||
|
assert "description qui manquait" in issue["body"]
|
||||||
|
# Le journal consigne l'édition.
|
||||||
|
events = client.get("/api/events?repo=Projets/demo").json()["events"]
|
||||||
|
assert any(e["event_type"] == "edited" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_update_rejects_empty_title(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/issues/Projets/demo/1/update",
|
||||||
|
data={"title": " ", "body": "x", "csrf_token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_detail_includes_assets_support(client, fake_gitea):
|
||||||
|
data = client.get("/api/issues/Projets/demo/1").json()
|
||||||
|
assert data["issue"]["attachments_supported"] is True
|
||||||
|
assert data["issue"]["assets"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_roundtrip(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/issues/Projets/demo/1/assets",
|
||||||
|
data={"csrf_token": token},
|
||||||
|
files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
asset = r.json()["asset"]
|
||||||
|
assert asset["name"] == "notes.txt"
|
||||||
|
assert asset["size"] == len(b"bonjour kanbante")
|
||||||
|
|
||||||
|
# Visible dans le détail.
|
||||||
|
data = client.get("/api/issues/Projets/demo/1").json()
|
||||||
|
assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"]
|
||||||
|
|
||||||
|
# Téléchargement proxifié : contenu + disposition.
|
||||||
|
dl = client.get(
|
||||||
|
f"/api/issues/Projets/demo/1/assets/{asset['id']}/download"
|
||||||
|
)
|
||||||
|
assert dl.status_code == 200
|
||||||
|
assert dl.content == b"bonjour kanbante"
|
||||||
|
assert "notes.txt" in dl.headers["content-disposition"]
|
||||||
|
|
||||||
|
# Suppression.
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete",
|
||||||
|
data={"csrf_token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = client.get("/api/issues/Projets/demo/1").json()
|
||||||
|
assert data["issue"]["assets"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_asset_upload_rejects_empty_and_huge(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/issues/Projets/demo/1/assets",
|
||||||
|
data={"csrf_token": token},
|
||||||
|
files={"attachment": ("vide.txt", b"", "text/plain")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
|
||||||
|
page = client.get("/board/Projets/demo")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert 'id="detail-edit-btn"' in page.text
|
||||||
|
assert 'id="detail-edit-form"' in page.text
|
||||||
|
assert 'id="detail-assets"' in page.text
|
||||||
|
assert 'id="asset-form"' in page.text
|
||||||
|
# Création avec description dépliable (0.31.1 : <details>
|
||||||
|
# natif plutôt qu'un bouton piloté en JS).
|
||||||
|
assert 'id="new-issue-body"' in page.text
|
||||||
|
assert 'id="new-issue-desc"' in page.text
|
||||||
|
assert 'class="composer"' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gitea_download_follows_browser_download_url():
|
||||||
|
"""Gitea renvoie la fiche JSON de l'asset, pas le binaire :
|
||||||
|
le téléchargement doit suivre browser_download_url (réel
|
||||||
|
1.27.3, attrapé au smoke de la 0.31.0)."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.gitea_client import GiteaClient
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
url = str(request.url)
|
||||||
|
if url.endswith("/assets/42"):
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": 42, "name": "preuve.txt",
|
||||||
|
"browser_download_url": "https://gitea.test/attachments/uuid-1",
|
||||||
|
})
|
||||||
|
if url.endswith("/attachments/uuid-1"):
|
||||||
|
assert request.headers.get("authorization", "").startswith("token ")
|
||||||
|
return httpx.Response(
|
||||||
|
200, content=b"contenu reel",
|
||||||
|
headers={"content-type": "text/plain"},
|
||||||
|
)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
client = GiteaClient(
|
||||||
|
base_url="https://gitea.test", token="jeton" + "-factice",
|
||||||
|
cache_ttl=0, transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
content, content_type = await client.download_issue_asset(
|
||||||
|
"Projets", "demo", 1, 42
|
||||||
|
)
|
||||||
|
assert content == b"contenu reel"
|
||||||
|
assert content_type == "text/plain"
|
||||||
Reference in New Issue
Block a user