Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46d6dc519 | ||
|
|
4184ba0272 | ||
|
|
751e2f4f44 | ||
|
|
975aff33a5 | ||
|
|
003e44f55e | ||
|
|
53f54b1b79 | ||
|
|
80fc260a40 | ||
|
|
8ad3a86e60 | ||
|
|
a92353f8ef | ||
|
|
8c889878ee | ||
|
|
dfb4e595ff | ||
|
|
9fe35f6c8f | ||
|
|
d419fe3086 | ||
|
|
65c467bc90 | ||
|
|
4a97e0d0dc | ||
|
|
7852d860fe | ||
|
|
d4cf8d1275 |
@@ -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=
|
||||||
+357
@@ -1,5 +1,362 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.37.1 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — version du service d'agents dans le panneau Spark (mode sidebar)
|
||||||
|
|
||||||
|
Carte fusion #64. Le pied du panneau Spark (`static/js/draco-panneau.js`)
|
||||||
|
affiche désormais la ligne discrète et permanente « Service d'agents
|
||||||
|
vX », comme la console pleine page. La version est lue **en direct**
|
||||||
|
dans la santé du service (`GET /healthz` de l'origine que le panneau
|
||||||
|
connaît déjà), à l'ouverture du panneau, avec cache de session — jamais
|
||||||
|
une valeur codée en dur ; service injoignable ou panneau non configuré
|
||||||
|
= rien d'affiché, aucune erreur visible.
|
||||||
|
|
||||||
|
## 0.37.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — tri « En attente de revue » sur le dashboard
|
||||||
|
|
||||||
|
Demande de Bruno : pouvoir ordonner les projets — section
|
||||||
|
**Épinglés** en tête — par ce qui attend sa validation.
|
||||||
|
|
||||||
|
- Nouveau critère dans le sélecteur de tri du dashboard,
|
||||||
|
en première place après « Par défaut » : **🔍 En attente
|
||||||
|
de revue ↓** — les projets s'ordonnent par le nombre de
|
||||||
|
cartes dans la colonne de validation (clé `review` de
|
||||||
|
l'espace), décroissant.
|
||||||
|
- Le compteur était déjà calculé pour les chips de
|
||||||
|
progression des cartes : il est maintenant exposé en
|
||||||
|
`data-review` sur chaque carte projet et consommé par le
|
||||||
|
moteur de tri partagé — le critère s'applique à **toutes
|
||||||
|
les grilles** (Épinglés, Actifs, Inactifs, Archivés),
|
||||||
|
avec ou sans regroupement par propriétaire, et la
|
||||||
|
préférence est mémorisée comme les autres tris.
|
||||||
|
- Les critères existants (activité Gitea, activité kanban,
|
||||||
|
issues ouvertes, nom) sont inchangés.
|
||||||
|
|
||||||
|
### Corrigé — sens des tris numériques du dashboard
|
||||||
|
|
||||||
|
Le contrôle en navigateur réel a révélé que le tri
|
||||||
|
« Issues ouvertes ↓ » triait en réalité **croissant**
|
||||||
|
depuis son introduction : le comparateur partagé inversait
|
||||||
|
les clés numériques. Les modes numériques (`open` et le
|
||||||
|
nouveau `review`) trient maintenant en soustraction de
|
||||||
|
clés — le plus grand compteur en premier, comme l'annonce
|
||||||
|
le « ↓ ». Vérifié sur les données réelles : Épinglés
|
||||||
|
ordonnés 196, 95, 32, 5, 4 en attente de revue.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.36.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — outil MCP `move_issue` (suivi de la vague H3 du service d'agents)
|
||||||
|
|
||||||
|
Le service d'agents sait proposer et appliquer des déplacements
|
||||||
|
de cartes par approbation (`kanbante.move_card`), mais le serveur
|
||||||
|
MCP de Kanbante n'exposait en écriture que `create_issue` et
|
||||||
|
`add_comment` : un déplacement approuvé aurait échoué faute
|
||||||
|
d'outil. Le trou est fermé.
|
||||||
|
|
||||||
|
- **Nouvel outil `move_issue`** (paramètres `owner`, `repo`,
|
||||||
|
`number`, `column`) : déplace une carte vers une autre colonne
|
||||||
|
en réutilisant le service de déplacement de l'interface
|
||||||
|
(`_do_move_card`, extrait de `POST /api/move` sans changement
|
||||||
|
de comportement) — board local, journal des événements
|
||||||
|
(source « mcp »), émission vers le journal du service
|
||||||
|
d'agents et miroir d'étiquette.
|
||||||
|
- **Étiquette kanban remplacée, les autres préservées** : le
|
||||||
|
déplacement retire l'ancienne étiquette `kanban:*` reconnue et
|
||||||
|
pose celle de la colonne cible ; les étiquettes libres de
|
||||||
|
l'issue (ex. « bug ») ne sont jamais touchées.
|
||||||
|
- **Colonne souple** : la clé (`review`), l'étiquette de forge
|
||||||
|
(`kanban:review`, alias d'espace compris) et le libellé
|
||||||
|
français affiché (« Révision », « À faire », « En cours »…)
|
||||||
|
sont acceptés ; les libellés viennent de la définition des
|
||||||
|
colonnes de l'espace, pas d'une table figée.
|
||||||
|
- **Garde doctrine** : la cible `done` (« Terminé ») est
|
||||||
|
**refusée avant toute écriture**, avec une erreur explicite —
|
||||||
|
Terminé appartient à Bruno dans son interface, les agents
|
||||||
|
s'arrêtent en Révision.
|
||||||
|
- **Erreurs propres** : colonne inconnue (avec la liste des
|
||||||
|
colonnes valides), dépôt ou issue inconnu, pull request
|
||||||
|
refusée comme n'étant pas une carte.
|
||||||
|
- Même garde que les autres outils /mcp (session, Basic ou
|
||||||
|
jeton d'API `kbt_`).
|
||||||
|
|
||||||
|
## 0.35.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — jalons en écriture depuis la page Projet (#26)
|
||||||
|
|
||||||
|
Seconde et dernière carte du backlog de la feuille de
|
||||||
|
route « Gestion de projets » : les jalons n'étaient que lus
|
||||||
|
dans Kanbante, ils s'écrivent maintenant — la forge reste
|
||||||
|
la source de vérité, rien n'est stocké localement.
|
||||||
|
|
||||||
|
- **Créer** un jalon (titre, description et échéance
|
||||||
|
optionnelles) depuis la page Projet, formulaire
|
||||||
|
« ➕ Nouveau jalon ».
|
||||||
|
- **Modifier** titre et échéance d'un jalon ouvert
|
||||||
|
(formulaire déplié par jalon ; échéance vidée = échéance
|
||||||
|
retirée, par un `null` explicite côté forge).
|
||||||
|
- **Fermer** un jalon, avec confirmation (ses issues
|
||||||
|
restent ouvertes).
|
||||||
|
- Écritures dans la forge via son API (`POST/PATCH
|
||||||
|
.../milestones`), CSRF vérifié, échéance invalide
|
||||||
|
refusée proprement **avant** toute écriture ; le board
|
||||||
|
filtré et le bandeau de progression reflètent le nouveau
|
||||||
|
jalon au chargement suivant, sans redémarrage.
|
||||||
|
- Échéance envoyée à **midi UTC** : Gitea renvoie les dates
|
||||||
|
dans le fuseau de son instance — à minuit UTC, l'échéance
|
||||||
|
affichée reculait d'un jour (attrapé au smoke réel).
|
||||||
|
- Hors périmètre (assumé par l'issue) : supprimer un jalon,
|
||||||
|
réassigner les issues entre jalons.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.34.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — vue « En vol partout » /inflight (issue #24)
|
||||||
|
|
||||||
|
Dernière carte du backlog de la feuille de route « Gestion
|
||||||
|
de projets », première du classement big picture (pilotage
|
||||||
|
quotidien des agents avant gestion de projet).
|
||||||
|
|
||||||
|
- **Page /inflight** : tout l'encours (colonnes en vol de
|
||||||
|
l'espace — En cours, QA, Révision par défaut) **tous
|
||||||
|
projets actifs confondus, regroupé par colonne** — là où
|
||||||
|
/focus groupe par projet et /my par assignation.
|
||||||
|
- Chaque carte : projet (lien board), titre, étiquettes,
|
||||||
|
assigné, et **âge dans la colonne** (badge ⏳, teinté
|
||||||
|
selon les seuils d'ancienneté) — les groupes sont **triés
|
||||||
|
par âge décroissant** : les plus vieilles cartes, le vrai
|
||||||
|
signal, en premier.
|
||||||
|
- En-tête : total en vol + compteurs par colonne ;
|
||||||
|
**limite WIP globale** affichée quand des projets en
|
||||||
|
définissent (somme par colonne).
|
||||||
|
- Entrées : menu latéral **Pilotage → 🛩️ En vol** et
|
||||||
|
palette Ctrl+K. Rafraîchissement doux par le SSE
|
||||||
|
`/api/stream` comme les autres vues (fermer une issue la
|
||||||
|
retire au rechargement).
|
||||||
|
- Réutilise le pipeline du Focus (`_collect_focus_groups`,
|
||||||
|
`_card_ages`) : aucun appel forge supplémentaire.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.33.0 — 2026-10-26
|
||||||
|
|
||||||
|
### Ajouté — filtrage professionnel sur « Mes issues » (/my)
|
||||||
|
|
||||||
|
Demande de Bruno : retrouver vite une issue parmi toutes
|
||||||
|
celles qui lui sont assignées (467 ouvertes au moment du
|
||||||
|
test réel).
|
||||||
|
|
||||||
|
- **Barre de filtres** en carte, dans la langue visuelle du
|
||||||
|
compositeur 0.31.1 : recherche texte plein champ
|
||||||
|
(titre, projet, #numéro, étiquettes — insensible aux
|
||||||
|
accents et à la casse, raccourci « / », Échap pour
|
||||||
|
effacer), listes **Projet** et **Étiquette** avec
|
||||||
|
compteurs, **chips de colonnes** colorées avec compteurs
|
||||||
|
(multi-sélection), **tri** (activité récente, plus
|
||||||
|
anciennes, titre, projet).
|
||||||
|
- **Performance** : filtrage 100 % côté client sur des
|
||||||
|
attributs précalculés côté serveur — aucun aller-retour
|
||||||
|
réseau par frappe, application en requestAnimationFrame.
|
||||||
|
Compteur en direct (« 24 sur 467 issues »), compteurs de
|
||||||
|
section mis à jour, état vide propre avec réinitialisation
|
||||||
|
en un clic.
|
||||||
|
- **Persistance** : projet, colonnes, étiquette et tri sont
|
||||||
|
retenus en localStorage d'une visite à l'autre (la
|
||||||
|
recherche texte reste éphémère) ; bouton « Réinitialiser
|
||||||
|
les filtres » dès qu'un filtre est actif.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.32.2 — 2026-10-25
|
||||||
|
|
||||||
|
### Corrigé — console du panneau morte et liens pas tous en dev : résolution par requête
|
||||||
|
|
||||||
|
Sur kanbante-dev, le mode console du panneau était une page morte
|
||||||
|
et les liens inter-apps n'étaient pas tous en dev. Couche fautive
|
||||||
|
trouvée dans `app/config.py` : la dérivation de la 0.32.1 ne
|
||||||
|
couvrait que les URL sœurs depuis `AGENT_EVENEMENTS_URL` — jamais
|
||||||
|
`agent_url` lui-même. Sur la dev, où seul le journal est déclaré
|
||||||
|
en `-dev`, le panneau recevait donc le défaut prod
|
||||||
|
`https://agent.dracodev.net` : iframe de console pointant un agent
|
||||||
|
prod pas encore installé, donc injoignable. Et aucune résolution
|
||||||
|
ne tenait compte de la requête : sans variable d'environnement,
|
||||||
|
un hôte public en `-dev` retombait entièrement sur la prod.
|
||||||
|
Enfin, aucune CSP n'existait : l'origine de l'agent n'était
|
||||||
|
énoncée nulle part.
|
||||||
|
|
||||||
|
- **Résolution par requête** (`resoudre_urls_draco` dans
|
||||||
|
`app/config.py`, consommée par `render()` dans `app/main.py`)
|
||||||
|
pour tout ce qui est rendu à une requête — liens de la barre
|
||||||
|
(`draco_urls`), source de l'iframe du panneau
|
||||||
|
(`DRACO_PANNEAU_CONFIG.api`) et origine agent de la CSP — dans
|
||||||
|
l'ordre : 1) la variable explicite de l'installation,
|
||||||
|
prioritaire et inchangée (`DRACO_SSO_URL` pour ObsiGate,
|
||||||
|
`DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
|
||||||
|
`KANBANTE_PUBLIC_URL` pour Kanbante elle-même, `AGENT_URL`
|
||||||
|
pour l'agent) ; 2) l'hôte public de la requête, motif fermé
|
||||||
|
`^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>\.dracodev\.net$`
|
||||||
|
(suffixe optionnel : vide en prod, `-dev` en dev), appliqué à
|
||||||
|
toutes les sœurs et à l'agent — jamais de suffixe inventé
|
||||||
|
depuis un hôte arbitraire ; hors motif (LAN, IP), étape
|
||||||
|
suivante ; 3) la dérivation par les adresses de l'agent connues
|
||||||
|
en configuration, **élargie** : URL d'événements **ou** base du
|
||||||
|
service, selon ce que l'installation déclare ; 4) le défaut
|
||||||
|
officiel.
|
||||||
|
- **Base de l'agent dérivée** (contextes sans requête aussi) :
|
||||||
|
sans `AGENT_URL` explicite, si l'adresse connue de l'agent
|
||||||
|
(événements ou base) suit `agent<SUFFIXE>.dracodev.net`,
|
||||||
|
`agent_url` devient `https://agent<SUFFIXE>.dracodev.net` au
|
||||||
|
lieu de rester au défaut prod.
|
||||||
|
- **CSP nouvelle** : chaque page rendue énonce
|
||||||
|
`Content-Security-Policy: frame-src 'self' <origine agent>` —
|
||||||
|
exactement l'origine résolue pour cette même réponse, celle de
|
||||||
|
l'iframe de la console (seule iframe de l'application).
|
||||||
|
- Les contextes sans requête gardent la résolution par
|
||||||
|
configuration, inchangée sinon. Aucun SSO ajouté : Kanbante
|
||||||
|
n'en a pas par conception ; périmètre limité à ses liens et à
|
||||||
|
son panneau.
|
||||||
|
- Tests : requête `Host: kanbante-dev.dracodev.net` sans aucune
|
||||||
|
variable → barre, iframe et CSP entièrement en `-dev` ; hôte
|
||||||
|
prod → noms officiels ; hôte LAN → repli configuration/défauts ;
|
||||||
|
explicite gagne sur l'hôte ; base de l'agent dérivée du seul
|
||||||
|
journal et dérivation élargie à la base du service ; contextes
|
||||||
|
sans requête inchangés. Suite complète : 278 tests réussis.
|
||||||
|
- **Déploiement** : cette version part avec le prochain
|
||||||
|
redéploiement Docker (la 0.32.1 n'a jamais été redéployée) —
|
||||||
|
preuve en direct **en attente du redéploiement Docker de
|
||||||
|
Bruno**, la version vivante sur kanbante-dev reste l'ancienne.
|
||||||
|
|
||||||
|
## 0.32.1 — 2026-10-25
|
||||||
|
|
||||||
|
### Corrigé — URL sœurs de la barre dérivées de l'environnement de l'agent
|
||||||
|
|
||||||
|
Sur la dev, le menu inter-apps pointait vers les noms **prod**
|
||||||
|
des apps sœurs : seul `AGENT_EVENEMENTS_URL` y est posé (hôte
|
||||||
|
`agent-dev.dracodev.net`), et chaque URL sœur retombait sur son
|
||||||
|
défaut officiel faute de variable explicite — il aurait fallu
|
||||||
|
déclarer quatre variables de plus dans le `.env` dev.
|
||||||
|
|
||||||
|
- **Résolution des URL sœurs** dans `app/config.py` (les
|
||||||
|
gabarits et le JS de la barre consomment `draco_urls`
|
||||||
|
inchangé), dans l'ordre : 1) la variable explicite —
|
||||||
|
`DRACO_SSO_URL` (ObsiGate), `DRACO_FLOWDECK_URL`,
|
||||||
|
`DRACO_TIKTAK_URL`, `KANBANTE_PUBLIC_URL` (Kanbante
|
||||||
|
elle-même) — prioritaire, contrat inchangé ; 2) **dérivation
|
||||||
|
d'environnement** depuis l'origine de l'agent telle que
|
||||||
|
l'app la connaît déjà (`AGENT_EVENEMENTS_URL`) : si son
|
||||||
|
hôte est de la forme `agent<SUFFIXE>.dracodev.net`
|
||||||
|
(suffixe vide ou, typiquement, `-dev`), l'URL sœur devient
|
||||||
|
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`,
|
||||||
|
`flowdeck`, `tiktak`, `kanbante`) ; hôte hors motif (autre
|
||||||
|
domaine, LAN, IP) = **aucune dérivation**, jamais de lien
|
||||||
|
deviné ; 3) le défaut officiel (prod), comme avant.
|
||||||
|
- L'URL Gitea ne change pas.
|
||||||
|
- Tests : avec le seul `AGENT_EVENEMENTS_URL` en `-dev`, les
|
||||||
|
quatre URL sœurs servies sont en `-dev` ; une URL explicite
|
||||||
|
gagne sur la dérivation ; hôte d'agent hors motif et aucun
|
||||||
|
environnement posé restent aux défauts officiels
|
||||||
|
(non-régression).
|
||||||
|
|
||||||
|
|
||||||
|
## 0.32.0 — 2026-10-25
|
||||||
|
|
||||||
|
### Refactor — noms publics dracodev par configuration (dev/prod)
|
||||||
|
|
||||||
|
Aucun nom public dracodev n'est plus codé en dur dans le code
|
||||||
|
vivant (gabarits, JS statique) : tout passe par `app/config.py`,
|
||||||
|
dont les défauts sont les noms **officiels** (prod). L'environnement
|
||||||
|
dev déclare ses noms en `-dev` dans son `.env`, sans toucher au code.
|
||||||
|
|
||||||
|
- **Nouvelles variables** : `KANBANTE_PUBLIC_URL` (propre URL
|
||||||
|
publique : lien de la barre + `return_to` SSO), `DRACO_FLOWDECK_URL`
|
||||||
|
et `DRACO_TIKTAK_URL` (liens de la barre commune ; le lien ObsiGate
|
||||||
|
réutilise `DRACO_SSO_URL`, déjà existant), `AGENT_URL` (panneau
|
||||||
|
Spark/console). `AGENT_EVENEMENTS_URL` se dérive désormais de
|
||||||
|
`AGENT_URL` quand il n'est pas posé explicitement (l'override
|
||||||
|
compose existant garde la priorité).
|
||||||
|
- **Barre commune** (`_draco_bar.html`) : les quatre liens d'apps
|
||||||
|
viennent de `draco_urls` injecté par `render()` ; le panneau
|
||||||
|
reçoit l'adresse du service via `window.DRACO_PANNEAU_CONFIG`
|
||||||
|
injecté avant `draco-panneau.js` (chargé avec le cache-busting
|
||||||
|
existant `?v={{ version }}`). `draco-panneau.js` ne contient plus
|
||||||
|
aucun nom : sans injection, il se déclare non configuré.
|
||||||
|
- **`login.html`** : le lien « Se connecter avec ObsiGate (SSO) »
|
||||||
|
est construit depuis la configuration (`sso_authorize_url`).
|
||||||
|
Note : ce lien SSO livré de longue date (Fusion #27) contredit le
|
||||||
|
document d'installation dev affirmant « pas de SSO par conception »
|
||||||
|
pour Kanbante — comportement existant conservé tel quel, juste
|
||||||
|
rendu configurable ; la contradiction documentaire est signalée,
|
||||||
|
pas tranchée ici.
|
||||||
|
- `git.dracodev.net` inchangé : infrastructure (un seul Gitea,
|
||||||
|
`GITEA_URL` / `KANBANTE_GITEA_URL` côté sync), hors renommage dev/prod.
|
||||||
|
- Tests barre/SSO mis en cohérence via la configuration (défauts
|
||||||
|
officiels + variantes `-dev`), `.env.example` créé, README et
|
||||||
|
`docker-compose.yml` documentent chaque variable touchée.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.31.1 — 2026-10-23
|
||||||
|
|
||||||
|
### Corrigé — compositeur de création refait
|
||||||
|
|
||||||
|
Retour de Bruno sur la 0.31.0 : la barre de création
|
||||||
|
n'était ni élégante ni professionnelle, et le bouton
|
||||||
|
« + Description » pouvait rester sans effet (écouteur
|
||||||
|
JavaScript non enregistré selon l'état du cache ou des
|
||||||
|
scripts de la page).
|
||||||
|
|
||||||
|
- **Compositeur en carte** : en-tête « Nouvelle issue »
|
||||||
|
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
|
||||||
|
nom personnalisé de l'espace respecté), champ titre
|
||||||
|
pleine largeur avec anneau de focus, bouton primaire
|
||||||
|
« Créer l'issue ».
|
||||||
|
- **Description en `<details>` natif** : « Ajouter une
|
||||||
|
description ▾ » déplie la zone texte sans aucun
|
||||||
|
JavaScript — impossible à casser par un script en
|
||||||
|
erreur ou un fichier en cache. Aide intégrée (Markdown,
|
||||||
|
Ctrl+Entrée pour créer, actif aussi depuis le titre).
|
||||||
|
- **Assets du board versionnés** (`?v=<version>` sur le
|
||||||
|
CSS et board.js du board) : un redéploiement invalide
|
||||||
|
le cache navigateur au lieu de servir un mélange
|
||||||
|
d'anciennes et nouvelles pièces.
|
||||||
|
- Vérifié en navigateur réel (Chromium) sur le vrai
|
||||||
|
board : captures fermé/ouvert contrôlées visuellement,
|
||||||
|
clic sur le libellé, zone visible et éditable.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.31.0 — 2026-10-23
|
||||||
|
|
||||||
|
### 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
|
## 0.30.0 — 2026-10-23
|
||||||
|
|
||||||
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
|
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
|
||||||
|
|||||||
@@ -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,341 @@
|
|||||||
|
"""Client de distribution du coffre de secrets ObsiGate (Phase 4, issue ObsiGate #22).
|
||||||
|
|
||||||
|
Patron imposé par la spec « Coffre de secrets ObsiGate » §12, appliqué ici
|
||||||
|
sans variante :
|
||||||
|
|
||||||
|
- Kanbante ne stocke QUE des références ``coffre:<slug>/<nom>``, jamais une
|
||||||
|
valeur distribuée ;
|
||||||
|
- à chaque usage, le SERVEUR Kanbante appelle
|
||||||
|
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
|
||||||
|
Kanbante (distinct du jeton MCP de notes et de la session SSO) et un
|
||||||
|
contexte obligatoire, utilise la valeur en mémoire, puis l'oublie ;
|
||||||
|
- aucun cache de valeur, aucune persistance, aucun log de la valeur ;
|
||||||
|
- octroi révoqué -> échec explicite orientant vers ObsiGate, sans repli
|
||||||
|
sur une ancienne valeur ;
|
||||||
|
- ObsiGate indisponible -> échec propre, sans continuer avec une valeur
|
||||||
|
en cache (il ne doit pas y en avoir).
|
||||||
|
|
||||||
|
Configuration (variables d'environnement, jamais versionnées) :
|
||||||
|
- ``KANBANTE_OBSIGATE_URL`` : base d'ObsiGate, ex. ``https://og.dracodev.net``
|
||||||
|
- ``KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`` : jeton du consommateur
|
||||||
|
« kanbante » enregistré dans ObsiGate (jeton révocable, hash + jti
|
||||||
|
côté ObsiGate ; il n'est jamais renvoyé au client ni journalisé).
|
||||||
|
- ``KANBANTE_COFFRE_TIMEOUT`` : délai en secondes (défaut 10).
|
||||||
|
|
||||||
|
Ce module ne dépend que de ``httpx`` (déjà dépendance du projet) et de
|
||||||
|
la bibliothèque standard, pour rester testable avec un faux endpoint
|
||||||
|
(``httpx.MockTransport``) sans instance ObsiGate réelle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("kanbante.coffre")
|
||||||
|
|
||||||
|
PREFIXE_REFERENCE = "coffre:"
|
||||||
|
CHEMIN_DISTRIBUER = "/api/coffres/distribuer"
|
||||||
|
TIMEOUT_DEFAUT = 10.0
|
||||||
|
|
||||||
|
# Messages explicites, orientant vers ObsiGate. Ils ne contiennent jamais
|
||||||
|
# la valeur distribuée, ni le jeton consommateur.
|
||||||
|
_MESSAGES_DETAIL = {
|
||||||
|
"consommateur_invalide": (
|
||||||
|
"Consommateur Kanbante invalide dans ObsiGate : "
|
||||||
|
"vérifier le jeton consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"consommateur_revoque": (
|
||||||
|
"Consommateur Kanbante révoqué dans ObsiGate : "
|
||||||
|
"ré-accorder l'accès du consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"octroi_absent": (
|
||||||
|
"Aucun octroi actif pour cette référence dans ObsiGate : "
|
||||||
|
"créer l'octroi pour le consommateur Kanbante dans ObsiGate."
|
||||||
|
),
|
||||||
|
"octroi_revoque": (
|
||||||
|
"Accès révoqué dans ObsiGate pour cette référence : "
|
||||||
|
"ré-accorder l'octroi dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"octroi_expire": (
|
||||||
|
"Octroi expiré dans ObsiGate pour cette référence : "
|
||||||
|
"renouveler l'octroi dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"secret_expire": (
|
||||||
|
"Secret expiré dans ObsiGate : "
|
||||||
|
"renouveler le secret dans ObsiGate, puis réessayer."
|
||||||
|
),
|
||||||
|
"reference_invalide": (
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
),
|
||||||
|
"contexte_manquant": (
|
||||||
|
"Contexte de distribution manquant : "
|
||||||
|
"un contexte (type + identifiant) est obligatoire côté ObsiGate."
|
||||||
|
),
|
||||||
|
"kek_absente": "ObsiGate indisponible : clé de chiffrement absente côté ObsiGate.",
|
||||||
|
"crypto_indisponible": "ObsiGate indisponible : chiffrement indisponible côté ObsiGate.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreErreur(Exception):
|
||||||
|
"""Erreur de distribution, sans jamais exposer une valeur secrète."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreReferenceInvalide(CoffreErreur):
|
||||||
|
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreContexteManquant(CoffreErreur):
|
||||||
|
"""Le contexte de distribution (type + identifiant) est obligatoire."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreNonConfigure(CoffreErreur):
|
||||||
|
"""ObsiGate ou le jeton consommateur Kanbante n'est pas configuré."""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreAccesRefuse(CoffreErreur):
|
||||||
|
"""ObsiGate a refusé la distribution (octroi/consommateur/secret).
|
||||||
|
|
||||||
|
Aucun repli sur une ancienne valeur n'est autorisé : l'appelant doit
|
||||||
|
échouer proprement avec ce message.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CoffreIndisponible(CoffreErreur):
|
||||||
|
"""ObsiGate est injoignable ou a répondu sans valeur utilisable."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CoffreConfig:
|
||||||
|
"""Configuration du consommateur Kanbante côté ObsiGate."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
jeton_consommateur: str
|
||||||
|
timeout: float = TIMEOUT_DEFAUT
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_distribuer(self) -> str:
|
||||||
|
return self.base_url.rstrip("/") + CHEMIN_DISTRIBUER
|
||||||
|
|
||||||
|
|
||||||
|
def config_depuis_env() -> CoffreConfig:
|
||||||
|
"""Construit la configuration depuis l'environnement.
|
||||||
|
|
||||||
|
Lève : CoffreNonConfigure si la base ou le jeton est absent.
|
||||||
|
Le jeton n'est jamais journalisé, même en cas d'erreur.
|
||||||
|
"""
|
||||||
|
base_url = os.getenv("KANBANTE_OBSIGATE_URL", "").strip()
|
||||||
|
jeton = os.getenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", "").strip()
|
||||||
|
try:
|
||||||
|
timeout = float(os.getenv("KANBANTE_COFFRE_TIMEOUT", "") or TIMEOUT_DEFAUT)
|
||||||
|
except ValueError:
|
||||||
|
timeout = TIMEOUT_DEFAUT
|
||||||
|
if not base_url or not jeton:
|
||||||
|
raise CoffreNonConfigure(
|
||||||
|
"Consommateur coffre Kanbante non configuré : définir "
|
||||||
|
"KANBANTE_OBSIGATE_URL et KANBANTE_COFFRE_CONSOMMATEUR_TOKEN."
|
||||||
|
)
|
||||||
|
return CoffreConfig(base_url=base_url, jeton_consommateur=jeton, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def est_reference(valeur: str | None) -> bool:
|
||||||
|
"""Vrai si ``valeur`` est une référence coffre (et non une valeur brute)."""
|
||||||
|
if not valeur:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parse_reference(valeur)
|
||||||
|
except CoffreReferenceInvalide:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reference(reference: str) -> tuple[str, str]:
|
||||||
|
"""Valide ``coffre:<slug>/<nom>`` et retourne ``(slug, nom)``.
|
||||||
|
|
||||||
|
Lève : CoffreReferenceInvalide si le format est incorrect.
|
||||||
|
La validation reste volontairement permissive sur les caractères
|
||||||
|
(ObsiGate reste l'autorité de validation) : préfixe, un « / »,
|
||||||
|
slug et nom non vides, aucun espace.
|
||||||
|
"""
|
||||||
|
reference = (reference or "").strip()
|
||||||
|
if not reference.startswith(PREFIXE_REFERENCE):
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
reste = reference[len(PREFIXE_REFERENCE):]
|
||||||
|
if "/" not in reste or any(c.isspace() for c in reference):
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
slug, nom = reste.split("/", 1)
|
||||||
|
if not slug or not nom or "/" in nom:
|
||||||
|
raise CoffreReferenceInvalide(
|
||||||
|
"Référence invalide (format attendu coffre:<slug>/<nom>)."
|
||||||
|
)
|
||||||
|
return slug, nom
|
||||||
|
|
||||||
|
|
||||||
|
def _valider_contexte(contexte_type: str, contexte_id: str) -> None:
|
||||||
|
if not (contexte_type or "").strip() or not (contexte_id or "").strip():
|
||||||
|
raise CoffreContexteManquant(
|
||||||
|
"Contexte de distribution manquant : un contexte "
|
||||||
|
"(type + identifiant) est obligatoire."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_erreur(statut: int, detail: str) -> CoffreErreur:
|
||||||
|
"""Mappe une réponse d'erreur ObsiGate vers une erreur typée.
|
||||||
|
|
||||||
|
Les statuts 401/403/410 signalent un refus d'accès (octroi,
|
||||||
|
consommateur ou secret) : jamais de repli. Les autres erreurs
|
||||||
|
(400 de format, 503, réponse sans valeur) sont des indisponibilités
|
||||||
|
ou des erreurs de configuration, également sans repli.
|
||||||
|
"""
|
||||||
|
message = _MESSAGES_DETAIL.get(detail)
|
||||||
|
if statut in (401, 403, 410) or detail in (
|
||||||
|
"octroi_absent",
|
||||||
|
"octroi_revoque",
|
||||||
|
"octroi_expire",
|
||||||
|
"consommateur_invalide",
|
||||||
|
"consommateur_revoque",
|
||||||
|
"secret_expire",
|
||||||
|
):
|
||||||
|
return CoffreAccesRefuse(message or f"Distribution refusée par ObsiGate ({detail}).")
|
||||||
|
if detail == "reference_invalide":
|
||||||
|
return CoffreReferenceInvalide(message or "Référence invalide.")
|
||||||
|
if detail == "contexte_manquant":
|
||||||
|
return CoffreContexteManquant(message or "Contexte manquant.")
|
||||||
|
return CoffreIndisponible(
|
||||||
|
message or f"ObsiGate indisponible (statut {statut})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _traiter_reponse(reponse: httpx.Response, reference: str) -> str:
|
||||||
|
"""Extrait la valeur d'une réponse de distribution, ou lève.
|
||||||
|
|
||||||
|
La valeur extraite n'est jamais journalisée ; seuls la référence,
|
||||||
|
le contexte et le statut le sont.
|
||||||
|
"""
|
||||||
|
if reponse.status_code == 200:
|
||||||
|
try:
|
||||||
|
valeur = reponse.json().get("valeur")
|
||||||
|
except ValueError:
|
||||||
|
valeur = None
|
||||||
|
if isinstance(valeur, str) and valeur:
|
||||||
|
logger.info("distribution coffre ok (reference=%s)", reference)
|
||||||
|
return valeur
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate a répondu sans valeur distribuable."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
detail = str(reponse.json().get("detail") or "")
|
||||||
|
except ValueError:
|
||||||
|
detail = ""
|
||||||
|
logger.warning(
|
||||||
|
"distribution coffre refusee (reference=%s, statut=%d, detail=%s)",
|
||||||
|
reference,
|
||||||
|
reponse.status_code,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
raise _message_erreur(reponse.status_code, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _corps_requete(reference: str, contexte_type: str, contexte_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"reference": reference,
|
||||||
|
"contexte_type": contexte_type,
|
||||||
|
"contexte_id": contexte_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def distribuer(
|
||||||
|
reference: str,
|
||||||
|
contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Distribue une valeur ObsiGate, en mémoire seulement.
|
||||||
|
|
||||||
|
L'appelant doit utiliser la valeur immédiatement (appel sortant,
|
||||||
|
vérification HMAC) puis la laisser sortir de portée : ce module ne
|
||||||
|
la persiste ni ne la met en cache, et ne la journalise jamais.
|
||||||
|
"""
|
||||||
|
parse_reference(reference)
|
||||||
|
_valider_contexte(contexte_type, contexte_id)
|
||||||
|
cfg = config or config_depuis_env()
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=cfg.timeout, transport=transport) as client:
|
||||||
|
reponse = client.post(
|
||||||
|
cfg.url_distribuer,
|
||||||
|
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||||
|
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate injoignable pour la distribution : "
|
||||||
|
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||||
|
) from exc
|
||||||
|
return _traiter_reponse(reponse, reference)
|
||||||
|
|
||||||
|
|
||||||
|
async def distribuer_async(
|
||||||
|
reference: str,
|
||||||
|
contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Variante asynchrone de :func:`distribuer` (mêmes garanties)."""
|
||||||
|
parse_reference(reference)
|
||||||
|
_valider_contexte(contexte_type, contexte_id)
|
||||||
|
cfg = config or config_depuis_env()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=cfg.timeout, transport=transport) as client:
|
||||||
|
reponse = await client.post(
|
||||||
|
cfg.url_distribuer,
|
||||||
|
json=_corps_requete(reference, contexte_type, contexte_id),
|
||||||
|
headers={"Authorization": f"Bearer {cfg.jeton_consommateur}"},
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CoffreIndisponible(
|
||||||
|
"ObsiGate injoignable pour la distribution : "
|
||||||
|
"vérifier qu'ObsiGate est disponible, puis réessayer."
|
||||||
|
) from exc
|
||||||
|
return _traiter_reponse(reponse, reference)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def usage_distribue(reference: str, contexte_type: str, contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.BaseTransport | None = None):
|
||||||
|
"""Distribue une valeur pour un seul usage, dans le bloc ``with``.
|
||||||
|
|
||||||
|
La valeur ne doit pas sortir du bloc (ni être stockée, ni journalisée,
|
||||||
|
ni retournée) : à la sortie, l'appelant ne conserve que le résultat
|
||||||
|
de l'usage, jamais la valeur elle-même.
|
||||||
|
"""
|
||||||
|
valeur = distribuer(reference, contexte_type, contexte_id,
|
||||||
|
config=config, transport=transport)
|
||||||
|
try:
|
||||||
|
yield valeur
|
||||||
|
finally:
|
||||||
|
valeur = ""
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def usage_distribue_async(reference: str, contexte_type: str,
|
||||||
|
contexte_id: str,
|
||||||
|
config: CoffreConfig | None = None,
|
||||||
|
transport: httpx.AsyncBaseTransport | None = None):
|
||||||
|
"""Variante asynchrone de :func:`usage_distribue`."""
|
||||||
|
valeur = await distribuer_async(reference, contexte_type, contexte_id,
|
||||||
|
config=config, transport=transport)
|
||||||
|
try:
|
||||||
|
yield valeur
|
||||||
|
finally:
|
||||||
|
valeur = ""
|
||||||
+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.30.0"
|
APP_VERSION = "0.37.1"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -149,6 +213,144 @@ def _env_wip_limits(name: str) -> dict[str, int]:
|
|||||||
return limits
|
return limits
|
||||||
|
|
||||||
|
|
||||||
|
# ── Résolution d'environnement des URL publiques dracodev ──
|
||||||
|
# 0.32.1 : sur la dev, seul AGENT_EVENEMENTS_URL est posé (hôte
|
||||||
|
# agent-dev.dracodev.net) : les URL sœurs retombaient donc sur leurs
|
||||||
|
# défauts prod dans la barre commune. La dérivation depuis l'hôte de
|
||||||
|
# l'agent évite de devoir déclarer quatre variables de plus.
|
||||||
|
# 0.32.2 — couche fautive corrigée : cette dérivation ne couvrait
|
||||||
|
# que les URL sœurs, jamais ``agent_url`` lui-même — le panneau
|
||||||
|
# recevait donc le défaut prod (agent injoignable, la prod n'étant
|
||||||
|
# pas installée) et la console était une page morte sur la dev.
|
||||||
|
# Et aucune résolution ne tenait compte de la requête : un hôte
|
||||||
|
# public en ``-dev`` sans aucune variable retombait sur la prod.
|
||||||
|
# Désormais, pour tout ce qui est rendu à une requête (liens de la
|
||||||
|
# barre / DRACO_URLS, source de l'iframe du panneau, origine agent
|
||||||
|
# de la CSP) : 1) variable explicite ; 2) hôte public de la
|
||||||
|
# requête (motif fermé ci-dessous) ; 3) dérivation par les adresses
|
||||||
|
# de l'agent connues en configuration — URL d'événements OU base
|
||||||
|
# du service (élargie en 0.32.2) ; 4) défaut officiel.
|
||||||
|
_MOTIF_HOTE_AGENT = re.compile(r"agent([a-z0-9-]*)\.dracodev\.net")
|
||||||
|
_MOTIF_HOTE_PUBLIC = re.compile(
|
||||||
|
r"(?:og|flowdeck|tiktak|kanbante|agent)([a-z0-9-]*)\.dracodev\.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_depuis_hote(hote: str, motif: re.Pattern) -> str | None:
|
||||||
|
"""Suffixe d'environnement porté par un hôte nu, ou ``None``.
|
||||||
|
|
||||||
|
Retourne le suffixe (``""`` pour le nom officiel, ``"-dev"``
|
||||||
|
pour la dev) si l'hôte suit le motif fermé ; ``None`` hors
|
||||||
|
motif (autre domaine, LAN, IP…) — aucune dérivation alors,
|
||||||
|
jamais de suffixe inventé depuis un hôte arbitraire.
|
||||||
|
"""
|
||||||
|
correspondance = motif.fullmatch(hote.strip().lower())
|
||||||
|
return correspondance.group(1) if correspondance else None
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_environnement_agent(url_agent: str) -> str | None:
|
||||||
|
"""Suffixe lu sur l'hôte d'une adresse connue de l'agent.
|
||||||
|
|
||||||
|
Vaut pour l'URL d'événements comme pour la base du service :
|
||||||
|
si son hôte est ``agent<SUFFIXE>.dracodev.net``, le suffixe
|
||||||
|
est retourné ; ``None`` hors motif.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hote = urlparse(url_agent).hostname or ""
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_AGENT)
|
||||||
|
|
||||||
|
|
||||||
|
def suffixe_environnement_requete(hote_requete: str | None) -> str | None:
|
||||||
|
"""Suffixe lu sur l'hôte public de la requête entrante.
|
||||||
|
|
||||||
|
Motif fermé ``^(og|flowdeck|tiktak|kanbante|agent)<SUFFIXE>
|
||||||
|
\\.dracodev\\.net$`` (suffixe optionnel : vide en prod,
|
||||||
|
``-dev`` en dev) ; un port éventuel est toléré et retiré.
|
||||||
|
Hors motif (LAN, IP, autre domaine) : ``None`` — l'appelant
|
||||||
|
retombe alors sur la configuration, jamais sur un suffixe
|
||||||
|
deviné.
|
||||||
|
"""
|
||||||
|
if not hote_requete:
|
||||||
|
return None
|
||||||
|
hote = hote_requete.strip().lower()
|
||||||
|
if hote.startswith("["): # littéral IPv6 : jamais dans le motif
|
||||||
|
return None
|
||||||
|
hote = hote.split(":", 1)[0]
|
||||||
|
return _suffixe_depuis_hote(hote, _MOTIF_HOTE_PUBLIC)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_soeur_draco(
|
||||||
|
nom_env: str, slug: str, defaut: str, suffixe: str | None
|
||||||
|
) -> str:
|
||||||
|
"""Résout l'URL publique d'une app sœur de la barre commune.
|
||||||
|
|
||||||
|
1) ``nom_env`` explicite — prioritaire, contrat inchangé ;
|
||||||
|
2) dérivation d'environnement : ``https://<slug><suffixe>.
|
||||||
|
dracodev.net`` quand l'hôte de l'agent suit le motif ;
|
||||||
|
3) défaut officiel (prod), comme avant.
|
||||||
|
"""
|
||||||
|
explicite = os.getenv(nom_env)
|
||||||
|
if explicite:
|
||||||
|
return explicite.rstrip("/")
|
||||||
|
if suffixe is not None:
|
||||||
|
return f"https://{slug}{suffixe}.dracodev.net"
|
||||||
|
return defaut
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UrlsDraco:
|
||||||
|
"""URL publiques dracodev résolues pour un rendu ou un contexte."""
|
||||||
|
|
||||||
|
sso_url: str # ObsiGate (lien de barre ; explicite = DRACO_SSO_URL)
|
||||||
|
flowdeck_url: str
|
||||||
|
tiktak_url: str
|
||||||
|
public_url: str # Kanbante elle-même (KANBANTE_PUBLIC_URL)
|
||||||
|
agent_url: str # base du service d'agents (panneau + CSP)
|
||||||
|
|
||||||
|
|
||||||
|
def resoudre_urls_draco(
|
||||||
|
hote_requete: str | None = None, base: "Settings | None" = None
|
||||||
|
) -> UrlsDraco:
|
||||||
|
"""Résout les URL publiques pour un rendu à une requête.
|
||||||
|
|
||||||
|
Ordre, par URL : 1) la variable explicite de l'installation —
|
||||||
|
prioritaire, inchangée (``DRACO_SSO_URL`` pour ObsiGate,
|
||||||
|
``KANBANTE_PUBLIC_URL`` pour Kanbante elle-même, ``AGENT_URL``
|
||||||
|
pour l'agent) ; 2) l'hôte public de la requête, s'il suit le
|
||||||
|
motif fermé dracodev : son suffixe d'environnement (``-dev``,
|
||||||
|
vide en prod) s'applique à toutes les sœurs et à l'agent ;
|
||||||
|
3) à défaut, la valeur de configuration ``base`` (défaut :
|
||||||
|
``_env_settings()``), qui porte déjà la dérivation par les
|
||||||
|
adresses de l'agent connues (événements ou base du service)
|
||||||
|
puis le défaut officiel — c'est aussi la résolution des
|
||||||
|
contextes sans requête, inchangée.
|
||||||
|
"""
|
||||||
|
if base is None:
|
||||||
|
base = _env_settings()
|
||||||
|
suffixe_hote = suffixe_environnement_requete(hote_requete)
|
||||||
|
|
||||||
|
def resoudre(nom_env: str, slug: str, valeur_base: str) -> str:
|
||||||
|
if os.getenv(nom_env):
|
||||||
|
return valeur_base # explicite : déjà résolue dans base
|
||||||
|
if suffixe_hote is not None:
|
||||||
|
return f"https://{slug}{suffixe_hote}.dracodev.net"
|
||||||
|
return valeur_base
|
||||||
|
|
||||||
|
return UrlsDraco(
|
||||||
|
sso_url=resoudre("DRACO_SSO_URL", "og", base.sso_url),
|
||||||
|
flowdeck_url=resoudre(
|
||||||
|
"DRACO_FLOWDECK_URL", "flowdeck", base.flowdeck_url
|
||||||
|
),
|
||||||
|
tiktak_url=resoudre("DRACO_TIKTAK_URL", "tiktak", base.tiktak_url),
|
||||||
|
public_url=resoudre(
|
||||||
|
"KANBANTE_PUBLIC_URL", "kanbante", base.public_url
|
||||||
|
),
|
||||||
|
agent_url=resoudre("AGENT_URL", "agent", base.agent_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
gitea_url: str = "https://git.dracodev.net"
|
gitea_url: str = "https://git.dracodev.net"
|
||||||
@@ -188,6 +390,10 @@ class Settings:
|
|||||||
draco_secret_prev: str = ""
|
draco_secret_prev: str = ""
|
||||||
sso_machine_key: str = ""
|
sso_machine_key: str = ""
|
||||||
sso_url: str = "https://og.dracodev.net"
|
sso_url: str = "https://og.dracodev.net"
|
||||||
|
public_url: str = "https://kanbante.dracodev.net"
|
||||||
|
flowdeck_url: str = "https://flowdeck.dracodev.net"
|
||||||
|
tiktak_url: str = "https://tiktak.dracodev.net"
|
||||||
|
agent_url: str = "https://agent.dracodev.net"
|
||||||
archive_suggest_days: int = 180
|
archive_suggest_days: int = 180
|
||||||
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
|
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
|
||||||
agent_evenements_cle: str = ""
|
agent_evenements_cle: str = ""
|
||||||
@@ -205,6 +411,29 @@ def get_settings() -> Settings:
|
|||||||
|
|
||||||
def _env_settings() -> Settings:
|
def _env_settings() -> Settings:
|
||||||
"""Build settings from the current environment only."""
|
"""Build settings from the current environment only."""
|
||||||
|
agent_url_explicite = os.getenv("AGENT_URL")
|
||||||
|
agent_url = (
|
||||||
|
agent_url_explicite or "https://agent.dracodev.net"
|
||||||
|
).rstrip("/")
|
||||||
|
agent_evenements_url = (
|
||||||
|
os.getenv("AGENT_EVENEMENTS_URL")
|
||||||
|
or f"{agent_url}/api/v1/evenements"
|
||||||
|
).rstrip("/")
|
||||||
|
# Origines de l'agent telles que l'app les connaît déjà : elles
|
||||||
|
# portent le suffixe d'environnement (-dev…) repris par les URL
|
||||||
|
# sœurs dépourvues de variable explicite. Dérivation élargie
|
||||||
|
# (0.32.2) : l'URL d'événements d'abord, puis la base du service
|
||||||
|
# elle-même — selon ce que l'installation déclare.
|
||||||
|
suffixe_agent = _suffixe_environnement_agent(agent_evenements_url)
|
||||||
|
if suffixe_agent is None:
|
||||||
|
suffixe_agent = _suffixe_environnement_agent(agent_url)
|
||||||
|
# Base du service dérivée à son tour (0.32.2) : sans AGENT_URL
|
||||||
|
# explicite, si l'adresse connue de l'agent est en ``-dev``, le
|
||||||
|
# panneau et la console doivent viser cette même origine —
|
||||||
|
# auparavant agent_url restait au défaut prod, d'où la console
|
||||||
|
# morte sur la dev alors que le journal était bien en dev.
|
||||||
|
if not agent_url_explicite and suffixe_agent is not None:
|
||||||
|
agent_url = f"https://agent{suffixe_agent}.dracodev.net"
|
||||||
return Settings(
|
return Settings(
|
||||||
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
||||||
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
||||||
@@ -247,12 +476,30 @@ def _env_settings() -> Settings:
|
|||||||
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
|
draco_secret=os.getenv("DRACO_SESSION_SECRET", ""),
|
||||||
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
||||||
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
||||||
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
sso_url=_url_soeur_draco(
|
||||||
|
"DRACO_SSO_URL", "og", "https://og.dracodev.net", suffixe_agent
|
||||||
|
),
|
||||||
|
public_url=_url_soeur_draco(
|
||||||
|
"KANBANTE_PUBLIC_URL",
|
||||||
|
"kanbante",
|
||||||
|
"https://kanbante.dracodev.net",
|
||||||
|
suffixe_agent,
|
||||||
|
),
|
||||||
|
flowdeck_url=_url_soeur_draco(
|
||||||
|
"DRACO_FLOWDECK_URL",
|
||||||
|
"flowdeck",
|
||||||
|
"https://flowdeck.dracodev.net",
|
||||||
|
suffixe_agent,
|
||||||
|
),
|
||||||
|
tiktak_url=_url_soeur_draco(
|
||||||
|
"DRACO_TIKTAK_URL",
|
||||||
|
"tiktak",
|
||||||
|
"https://tiktak.dracodev.net",
|
||||||
|
suffixe_agent,
|
||||||
|
),
|
||||||
|
agent_url=agent_url,
|
||||||
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
|
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
|
||||||
agent_evenements_url=os.getenv(
|
agent_evenements_url=agent_evenements_url,
|
||||||
"AGENT_EVENEMENTS_URL",
|
|
||||||
"https://agent.dracodev.net/api/v1/evenements",
|
|
||||||
).rstrip("/"),
|
|
||||||
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
|
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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.30.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"
|
||||||
|
|
||||||
@@ -476,6 +477,59 @@ class GiteaClient:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def create_milestone(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str,
|
||||||
|
description: str = "",
|
||||||
|
due_on: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Create a milestone on the forge (issue #26)."""
|
||||||
|
payload: dict = {"title": title, "description": description}
|
||||||
|
if due_on:
|
||||||
|
payload["due_on"] = due_on
|
||||||
|
r = await self.client.post(
|
||||||
|
f"/repos/{owner}/{repo}/milestones", json=payload
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
self._cache_clear()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def update_milestone(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
milestone_id: int,
|
||||||
|
title: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
due_on: str | None = None,
|
||||||
|
clear_due: bool = False,
|
||||||
|
state: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Patch a milestone (title, due date, state) — #26.
|
||||||
|
|
||||||
|
due_on=None + clear_due=False leaves the date alone;
|
||||||
|
clear_due=True sends an explicit null (Gitea clears it).
|
||||||
|
"""
|
||||||
|
payload: dict = {}
|
||||||
|
if title is not None:
|
||||||
|
payload["title"] = title
|
||||||
|
if description is not None:
|
||||||
|
payload["description"] = description
|
||||||
|
if clear_due:
|
||||||
|
payload["due_on"] = None
|
||||||
|
elif due_on:
|
||||||
|
payload["due_on"] = due_on
|
||||||
|
if state is not None:
|
||||||
|
payload["state"] = state
|
||||||
|
r = await self.client.patch(
|
||||||
|
f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
self._cache_clear()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
async def list_starred(self) -> list[dict]:
|
async def list_starred(self) -> list[dict]:
|
||||||
"""Repos starred by the token user (issue #20: pinning rides
|
"""Repos starred by the token user (issue #20: pinning rides
|
||||||
on Gitea stars — the forge stays the source of truth)."""
|
on Gitea stars — the forge stays the source of truth)."""
|
||||||
@@ -503,6 +557,87 @@ class GiteaClient:
|
|||||||
self._cache_clear()
|
self._cache_clear()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
async def update_issue(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Edit an issue's title and/or body (0.31.0)."""
|
||||||
|
payload: dict = {}
|
||||||
|
if title is not None:
|
||||||
|
payload["title"] = title
|
||||||
|
if body is not None:
|
||||||
|
payload["body"] = body
|
||||||
|
if not payload:
|
||||||
|
return await self.get_issue(owner, repo, number)
|
||||||
|
r = await self.client.patch(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{number}", json=payload
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
self._cache_clear()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
# ── Pièces jointes des issues (0.31.0) ────────────────
|
||||||
|
|
||||||
|
async def list_issue_assets(
|
||||||
|
self, owner: str, repo: str, number: int
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Attachments of an issue (Gitea assets)."""
|
||||||
|
return await self._get(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{number}/assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upload_issue_asset(
|
||||||
|
self,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
filename: str,
|
||||||
|
content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
) -> dict:
|
||||||
|
"""Attach a file to an issue; returns the asset record."""
|
||||||
|
r = await self.client.post(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{number}/assets",
|
||||||
|
files={"attachment": (filename, content, content_type)},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def delete_issue_asset(
|
||||||
|
self, owner: str, repo: str, number: int, asset_id: int
|
||||||
|
) -> None:
|
||||||
|
r = await self.client.delete(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def download_issue_asset(
|
||||||
|
self, owner: str, repo: str, number: int, asset_id: int
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""(content, content_type) of one attachment.
|
||||||
|
|
||||||
|
Gitea nuance (vérifiée en réel sur 1.27.3): GET on the
|
||||||
|
asset endpoint returns its JSON record, NOT the bytes —
|
||||||
|
the file itself lives at `browser_download_url`
|
||||||
|
(/attachments/<uuid>), fetched here with the connection's
|
||||||
|
token so private repos work too.
|
||||||
|
"""
|
||||||
|
record = await self._get(
|
||||||
|
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
|
||||||
|
)
|
||||||
|
url = record.get("browser_download_url") if isinstance(record, dict) else None
|
||||||
|
if not url:
|
||||||
|
raise ValueError("La forge ne fournit pas d'URL de téléchargement")
|
||||||
|
r = await self.client.get(url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content, r.headers.get(
|
||||||
|
"content-type", "application/octet-stream"
|
||||||
|
)
|
||||||
|
|
||||||
async def set_issue_state(
|
async def set_issue_state(
|
||||||
self, owner: str, repo: str, issue_number: int, state: str
|
self, owner: str, repo: str, issue_number: int, state: str
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
+748
-48
@@ -23,13 +23,14 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
||||||
from fastapi.responses import (
|
from fastapi.responses import (
|
||||||
HTMLResponse,
|
HTMLResponse,
|
||||||
JSONResponse,
|
JSONResponse,
|
||||||
@@ -41,7 +42,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings, resoudre_urls_draco
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
from app import code_links as code_links_service
|
from app import code_links as code_links_service
|
||||||
from app import digest as digest_service
|
from app import digest as digest_service
|
||||||
@@ -51,6 +52,7 @@ from app import forecast as forecast_service
|
|||||||
from app import rules as rules_service
|
from app import rules as rules_service
|
||||||
from app import sprint as sprint_service
|
from app import sprint as sprint_service
|
||||||
from app import connections as connections_service
|
from app import connections as connections_service
|
||||||
|
from app import coffre_client
|
||||||
from app import board_columns as bcols
|
from app import board_columns as bcols
|
||||||
from app.briefing import (
|
from app.briefing import (
|
||||||
format_briefing_text,
|
format_briefing_text,
|
||||||
@@ -121,6 +123,7 @@ EVENT_ICONS = {
|
|||||||
"closed": "✅",
|
"closed": "✅",
|
||||||
"reopened": "🔁",
|
"reopened": "🔁",
|
||||||
"commented": "💬",
|
"commented": "💬",
|
||||||
|
"edited": "✏️",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1147,6 +1150,12 @@ async def admin_create_connection(
|
|||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
"/admin/connections?err=Nom+et+URL+requis", status_code=303
|
||||||
)
|
)
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
slug = _unique_slug(_slugify(name), "connections")
|
slug = _unique_slug(_slugify(name), "connections")
|
||||||
conn_id = models.create_connection(
|
conn_id = models.create_connection(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
@@ -1191,6 +1200,12 @@ async def admin_update_connection(
|
|||||||
if token:
|
if token:
|
||||||
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
|
||||||
if hook_secret:
|
if hook_secret:
|
||||||
|
if hook_secret.startswith("coffre:") and not coffre_client.est_reference(hook_secret):
|
||||||
|
return RedirectResponse(
|
||||||
|
"/admin/connections?err=R%C3%A9f%C3%A9rence+coffre+invalide"
|
||||||
|
"+%28format+coffre%3A%3Cslug%3E%2F%3Cnom%3E%29",
|
||||||
|
status_code=303,
|
||||||
|
)
|
||||||
fields["webhook_secret"] = hook_secret
|
fields["webhook_secret"] = hook_secret
|
||||||
if fields:
|
if fields:
|
||||||
models.update_connection(conn_id, **fields)
|
models.update_connection(conn_id, **fields)
|
||||||
@@ -2117,6 +2132,7 @@ _NAV_BY_TEMPLATE = {
|
|||||||
"capture.html": "capture",
|
"capture.html": "capture",
|
||||||
"digest.html": "digest",
|
"digest.html": "digest",
|
||||||
"forecast.html": "forecast",
|
"forecast.html": "forecast",
|
||||||
|
"inflight.html": "inflight",
|
||||||
"rules.html": "rules",
|
"rules.html": "rules",
|
||||||
"health.html": "health",
|
"health.html": "health",
|
||||||
"health_project.html": "health",
|
"health_project.html": "health",
|
||||||
@@ -2144,6 +2160,31 @@ def render(
|
|||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
context.setdefault("csrf_token", token)
|
context.setdefault("csrf_token", token)
|
||||||
context.setdefault("version", APP_VERSION)
|
context.setdefault("version", APP_VERSION)
|
||||||
|
# Noms publics dracodev par configuration (dev/prod, 0.32.0) :
|
||||||
|
# aucun nom public n'est codé en dur dans les gabarits ni dans le
|
||||||
|
# JS statique — tout vient de app.config (défauts = noms
|
||||||
|
# officiels/prod ; la dev déclare ses noms en ``-dev`` par .env).
|
||||||
|
# Résolution par requête (0.32.2) : les URL rendues suivent
|
||||||
|
# l'hôte public de la requête quand aucune variable explicite
|
||||||
|
# ne les fixe (voir resoudre_urls_draco) — barre, panneau et
|
||||||
|
# CSP de cette même réponse consomment la même résolution.
|
||||||
|
_pub = get_settings()
|
||||||
|
_urls = resoudre_urls_draco(request.headers.get("host"), base=_pub)
|
||||||
|
context.setdefault(
|
||||||
|
"draco_urls",
|
||||||
|
{
|
||||||
|
"obsigate": _urls.sso_url,
|
||||||
|
"flowdeck": _urls.flowdeck_url,
|
||||||
|
"tiktak": _urls.tiktak_url,
|
||||||
|
"kanbante": _urls.public_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
context.setdefault("agent_url", _urls.agent_url)
|
||||||
|
context.setdefault(
|
||||||
|
"sso_authorize_url",
|
||||||
|
f"{_urls.sso_url}/sso/authorize?return_to="
|
||||||
|
f"{quote(_urls.public_url + '/auth/draco', safe='')}",
|
||||||
|
)
|
||||||
# Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune.
|
# Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune.
|
||||||
context.setdefault(
|
context.setdefault(
|
||||||
"draco_avatar", (get_settings().auth_user or "K")[:1].upper()
|
"draco_avatar", (get_settings().auth_user or "K")[:1].upper()
|
||||||
@@ -2173,6 +2214,15 @@ def render(
|
|||||||
context.setdefault("user_workspaces", [])
|
context.setdefault("user_workspaces", [])
|
||||||
html = _tpl_env.get_template(name).render(**context)
|
html = _tpl_env.get_template(name).render(**context)
|
||||||
response = HTMLResponse(html, status_code=status_code)
|
response = HTMLResponse(html, status_code=status_code)
|
||||||
|
# CSP du panneau (0.32.2) : la seule iframe de l'app est la
|
||||||
|
# console Spark, chargée depuis l'origine de l'agent résolue
|
||||||
|
# pour CETTE requête — la même que celle injectée au panneau
|
||||||
|
# (DRACO_PANNEAU_CONFIG), jamais une autre. Aucune CSP
|
||||||
|
# n'existait auparavant : l'origine de l'agent n'était énoncée
|
||||||
|
# nulle part.
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
f"frame-src 'self' {_urls.agent_url}"
|
||||||
|
)
|
||||||
if generated:
|
if generated:
|
||||||
response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True)
|
response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True)
|
||||||
return response
|
return response
|
||||||
@@ -3111,7 +3161,10 @@ async def project_page(request: Request, owner: str, repo: str):
|
|||||||
est = forecast_service.estimate(opened, weekly)
|
est = forecast_service.estimate(opened, weekly)
|
||||||
milestones.append(
|
milestones.append(
|
||||||
{
|
{
|
||||||
|
"id": m.get("id"),
|
||||||
"title": m.get("title", ""),
|
"title": m.get("title", ""),
|
||||||
|
"description": m.get("description") or "",
|
||||||
|
"due_iso": due_date.isoformat() if due_date else "",
|
||||||
"total": total,
|
"total": total,
|
||||||
"closed": closed,
|
"closed": closed,
|
||||||
"open": opened,
|
"open": opened,
|
||||||
@@ -3995,6 +4048,88 @@ async def focus(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_inflight(ws_repos, ws_id: int) -> dict:
|
||||||
|
"""The work in flight, grouped BY COLUMN (issue #24).
|
||||||
|
|
||||||
|
Reuses the Focus pipeline (per-project groups of cards in
|
||||||
|
the workspace's in-flight columns), then pivots: one group
|
||||||
|
per column, cards sorted by age in column, oldest first —
|
||||||
|
the oldest cards are the signal. Ages come from the same
|
||||||
|
_card_ages pipeline as the board.
|
||||||
|
"""
|
||||||
|
per_repo = await _collect_focus_groups(ws_repos, ws_id)
|
||||||
|
columns: dict[str, list[dict]] = {
|
||||||
|
key: [] for key in bcols.focus_keys(ws_id)
|
||||||
|
}
|
||||||
|
for group in per_repo:
|
||||||
|
full_name = group["full_name"]
|
||||||
|
cid = group.get("conn_id", registry.default_id)
|
||||||
|
open_issues = [c["issue"] for c in group["cards"]]
|
||||||
|
board = get_board_state(
|
||||||
|
full_name, workspace_id=ws_id, connection_id=cid
|
||||||
|
)
|
||||||
|
ages = _card_ages(
|
||||||
|
full_name, open_issues, board,
|
||||||
|
workspace_id=ws_id, connection_id=cid,
|
||||||
|
)
|
||||||
|
for card in group["cards"]:
|
||||||
|
age = ages.get(card["issue"]["number"]) or {}
|
||||||
|
columns.setdefault(card["column"], []).append(
|
||||||
|
{
|
||||||
|
"issue": card["issue"],
|
||||||
|
"repo": full_name,
|
||||||
|
"column": card["column"],
|
||||||
|
"age_days": age.get("days", 0),
|
||||||
|
"age_since": age.get("since", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups = []
|
||||||
|
total = 0
|
||||||
|
for key in bcols.keys(ws_id):
|
||||||
|
cards = columns.get(key)
|
||||||
|
if not cards:
|
||||||
|
continue
|
||||||
|
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
|
||||||
|
groups.append({"column": key, "cards": cards})
|
||||||
|
total += len(cards)
|
||||||
|
for key, cards in columns.items():
|
||||||
|
if key not in {g["column"] for g in groups} and cards:
|
||||||
|
cards.sort(key=lambda c: (-c["age_days"], c["repo"], c["issue"]["number"]))
|
||||||
|
groups.append({"column": key, "cards": cards})
|
||||||
|
total += len(cards)
|
||||||
|
# Limites WIP globales lisibles : somme, par colonne, des
|
||||||
|
# limites définies dans les projets (0.33.0 : lecture seule).
|
||||||
|
settings = get_settings()
|
||||||
|
wip_globals: dict[str, int] = {}
|
||||||
|
for _repo, limits in (settings.wip_limits or {}).items():
|
||||||
|
for col, limit in (limits or {}).items():
|
||||||
|
try:
|
||||||
|
wip_globals[col] = wip_globals.get(col, 0) + int(limit)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
wip_globals = {c: v for c, v in wip_globals.items() if v > 0}
|
||||||
|
return {"groups": groups, "total": total, "wip_globals": wip_globals}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/inflight", response_class=HTMLResponse)
|
||||||
|
async def inflight(request: Request):
|
||||||
|
"""Everything in flight, all projects, grouped by column (#24)."""
|
||||||
|
workspace, _role = _current_workspace(request)
|
||||||
|
ws_id = workspace["id"] if workspace else 1
|
||||||
|
ws_repos, _ws = await _workspace_repos(request)
|
||||||
|
data = await _collect_inflight(ws_repos, ws_id)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"inflight.html",
|
||||||
|
groups=data["groups"],
|
||||||
|
total=data["total"],
|
||||||
|
wip_globals=data["wip_globals"],
|
||||||
|
column_labels=bcols.names(ws_id),
|
||||||
|
column_colors=bcols.colors(ws_id),
|
||||||
|
aging_days=get_settings().aging_days,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _group_issue_results(issues: list[dict]) -> list[dict]:
|
def _group_issue_results(issues: list[dict]) -> list[dict]:
|
||||||
"""Group search results by repo, annotated with the board column."""
|
"""Group search results by repo, annotated with the board column."""
|
||||||
by_repo: dict[str, list[dict]] = {}
|
by_repo: dict[str, list[dict]] = {}
|
||||||
@@ -4210,17 +4345,181 @@ async def my_issues(request: Request):
|
|||||||
logger.debug("search failed for /my (conn %s): %s", cid, exc)
|
logger.debug("search failed for /my (conn %s): %s", cid, exc)
|
||||||
total = len(results)
|
total = len(results)
|
||||||
groups = _group_issue_results(results)
|
groups = _group_issue_results(results)
|
||||||
|
# Facettes de filtrage (0.33.0) : compteurs calculés une
|
||||||
|
# fois côté serveur ; le filtrage lui-même est instantané
|
||||||
|
# côté client, sur des attributs data-* des lignes.
|
||||||
|
names = _ws_names(request)
|
||||||
|
order = _ws_keys(request)
|
||||||
|
repo_facets = [
|
||||||
|
{"full_name": g["full_name"], "count": len(g["rows"])}
|
||||||
|
for g in groups
|
||||||
|
]
|
||||||
|
col_counts: dict[str, int] = {}
|
||||||
|
label_counts: dict[str, int] = {}
|
||||||
|
for g in groups:
|
||||||
|
for row in g["rows"]:
|
||||||
|
col_counts[row["column"]] = col_counts.get(row["column"], 0) + 1
|
||||||
|
for label in row["issue"].get("labels") or []:
|
||||||
|
name = label.get("name") or ""
|
||||||
|
if name:
|
||||||
|
label_counts[name] = label_counts.get(name, 0) + 1
|
||||||
|
column_facets = [
|
||||||
|
{"key": key, "count": col_counts[key]}
|
||||||
|
for key in order
|
||||||
|
if key in col_counts
|
||||||
|
] + [
|
||||||
|
{"key": key, "count": count}
|
||||||
|
for key, count in col_counts.items()
|
||||||
|
if key not in order
|
||||||
|
]
|
||||||
|
label_facets = [
|
||||||
|
{"name": name, "count": label_counts[name]}
|
||||||
|
for name in sorted(label_counts, key=str.lower)
|
||||||
|
]
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"my.html",
|
"my.html",
|
||||||
user=user,
|
user=user,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
total=total,
|
total=total,
|
||||||
column_labels=_ws_names(request),
|
column_labels=names,
|
||||||
column_colors=_ws_colors(request),
|
column_colors=_ws_colors(request),
|
||||||
|
repo_facets=repo_facets,
|
||||||
|
column_facets=column_facets,
|
||||||
|
label_facets=label_facets,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _milestone_due_iso(due: str) -> tuple[str | None, bool]:
|
||||||
|
"""Validate a YYYY-MM-DD form date -> (ISO instant, ok)."""
|
||||||
|
due = (due or "").strip()
|
||||||
|
if not due:
|
||||||
|
return None, True
|
||||||
|
try:
|
||||||
|
parsed = datetime.strptime(due, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return None, False
|
||||||
|
# Midi UTC, pas minuit : Gitea renvoie l'échéance dans le
|
||||||
|
# fuseau de son instance (Toronto = UTC-5) — à minuit UTC,
|
||||||
|
# la date affichée reculait d'un jour (attrapé en réel).
|
||||||
|
return parsed.strftime("%Y-%m-%dT12:00:00Z"), True
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/repos/{owner}/{repo}/milestones")
|
||||||
|
async def api_milestone_create(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
title: str = Form(""),
|
||||||
|
description: str = Form(""),
|
||||||
|
due_on: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Create a milestone on the forge (issue #26)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
title = title.strip()
|
||||||
|
if not title or len(title) > 255:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Titre requis (255 caractères max)"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
due_iso, ok = _milestone_due_iso(due_on)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
try:
|
||||||
|
milestone = await client.create_milestone(
|
||||||
|
owner, repo, title, description.strip(), due_iso
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": True, "milestone": {
|
||||||
|
"id": milestone.get("id"),
|
||||||
|
"title": milestone.get("title"),
|
||||||
|
"due_on": milestone.get("due_on"),
|
||||||
|
}},
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/update")
|
||||||
|
async def api_milestone_update(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
milestone_id: int,
|
||||||
|
title: str = Form(""),
|
||||||
|
due_on: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Edit a milestone's title / due date (issue #26).
|
||||||
|
|
||||||
|
An empty due date CLEARS the milestone's deadline.
|
||||||
|
"""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
title = title.strip()
|
||||||
|
if not title or len(title) > 255:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Titre requis (255 caractères max)"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
due_iso, ok = _milestone_due_iso(due_on)
|
||||||
|
if not ok:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
try:
|
||||||
|
milestone = await client.update_milestone(
|
||||||
|
owner, repo, milestone_id, title=title,
|
||||||
|
due_on=due_iso, clear_due=due_iso is None,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return {"ok": True, "milestone": {
|
||||||
|
"id": milestone.get("id"),
|
||||||
|
"title": milestone.get("title"),
|
||||||
|
"due_on": milestone.get("due_on"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/close")
|
||||||
|
async def api_milestone_close(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
milestone_id: int,
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Close a milestone on the forge (issue #26)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
try:
|
||||||
|
await client.update_milestone(
|
||||||
|
owner, repo, milestone_id, state="closed"
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Briefing ───────────────────────────────────────────────
|
# ── Briefing ───────────────────────────────────────────────
|
||||||
|
|
||||||
async def _briefing_data() -> dict:
|
async def _briefing_data() -> dict:
|
||||||
@@ -4593,7 +4892,50 @@ async def _handle_forge_webhook(request: Request, conn_row):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
raw = await request.body()
|
raw = await request.body()
|
||||||
kind = conn_row["kind"] if conn_row else "gitea"
|
kind = conn_row["kind"] if conn_row else "gitea"
|
||||||
secret = (conn_row or {}).get("webhook_secret") or settings.webhook_secret
|
# Secret webhook : une référence coffre:<slug>/<nom> stockée dans
|
||||||
|
# forge_connections.webhook_secret est distribuée à chaque webhook
|
||||||
|
# (Phase 4, issue ObsiGate #22), utilisée en mémoire pour ce seul
|
||||||
|
# HMAC, puis oubliée. Aucun repli : si la distribution échoue
|
||||||
|
# (octroi révoqué, ObsiGate indisponible ou consommateur non
|
||||||
|
# configuré), le webhook échoue proprement avec un message explicite
|
||||||
|
# orientant vers ObsiGate, sans retomber sur le secret d'environnement
|
||||||
|
# ni sur une ancienne valeur (il n'y en a pas de stockée).
|
||||||
|
# Les valeurs historiques en clair et le repli environnement restent
|
||||||
|
# inchangés (compatibilité) tant qu'aucune référence n'est saisie.
|
||||||
|
stocke = (conn_row or {}).get("webhook_secret") or ""
|
||||||
|
if coffre_client.est_reference(stocke):
|
||||||
|
try:
|
||||||
|
secret = await coffre_client.distribuer_async(
|
||||||
|
stocke,
|
||||||
|
contexte_type="webhook",
|
||||||
|
contexte_id=(conn_row or {}).get("slug") or "gitea",
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreAccesRefuse as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Accès révoqué ou refusé dans ObsiGate : "
|
||||||
|
"ré-accorder l'octroi du secret webhook pour le "
|
||||||
|
"consommateur Kanbante dans ObsiGate, puis renvoyer "
|
||||||
|
"le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except coffre_client.CoffreErreur as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "ObsiGate indisponible ou consommateur Kanbante "
|
||||||
|
"non configuré : impossible de vérifier ce webhook "
|
||||||
|
"sans distribuer son secret. Vérifier ObsiGate, puis "
|
||||||
|
"renvoyer le webhook.",
|
||||||
|
"detail": str(exc),
|
||||||
|
},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
secret = stocke or settings.webhook_secret
|
||||||
if secret:
|
if secret:
|
||||||
if kind == "github":
|
if kind == "github":
|
||||||
signature = request.headers.get("x-hub-signature-256", "")
|
signature = request.headers.get("x-hub-signature-256", "")
|
||||||
@@ -5117,38 +5459,34 @@ async def activity(request: Request):
|
|||||||
|
|
||||||
# ── Routes: API ────────────────────────────────────────────
|
# ── Routes: API ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/move")
|
async def _do_move_card(
|
||||||
async def api_move(
|
repo_full_name: str,
|
||||||
request: Request,
|
issue_number: int,
|
||||||
repo_full_name: str = Form(...),
|
to_column: str,
|
||||||
issue_number: int = Form(...),
|
*,
|
||||||
to_column: str = Form(...),
|
from_column: str = "",
|
||||||
from_column: str = Form(""),
|
to_order: str = "",
|
||||||
to_order: str = Form(""),
|
from_order: str = "",
|
||||||
from_order: str = Form(""),
|
client,
|
||||||
csrf_token: str = Form(""),
|
conn_id: int,
|
||||||
):
|
ws_id: int,
|
||||||
"""Move an issue to a new column and persist the resulting order.
|
source: str = "ui",
|
||||||
|
) -> dict:
|
||||||
|
"""Déplacement effectif d'une carte, une fois la portée résolue.
|
||||||
|
|
||||||
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
|
Board local (placement + ordres), journal des événements,
|
||||||
the issue in Gitea and moving out of "done" reopens it. A failed
|
write-back Gitea (fermeture en entrant dans « done »,
|
||||||
write-back is reported in the response (gitea_sync="failed") instead
|
réouverture en en sortant — échecs rapportés, jamais
|
||||||
of failing the move itself.
|
bloquants) et miroir de l'étiquette kanban:* de la colonne
|
||||||
|
(les autres étiquettes de l'issue sont préservées).
|
||||||
|
|
||||||
|
Partagé par POST /api/move (interface, source « ui ») et par
|
||||||
|
l'outil MCP move_issue (source « mcp ») — même patron que
|
||||||
|
_do_capture pour la création. L'outil MCP refuse la colonne
|
||||||
|
« done » en amont : y entrer ferme l'issue, et Terminé
|
||||||
|
appartient à Bruno dans son interface.
|
||||||
"""
|
"""
|
||||||
if not _csrf_valid(request, csrf_token):
|
owner, repo = _split_full_name(repo_full_name)
|
||||||
return _csrf_error()
|
|
||||||
parts = _split_full_name(repo_full_name)
|
|
||||||
if parts is None:
|
|
||||||
return JSONResponse(
|
|
||||||
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
|
|
||||||
)
|
|
||||||
owner, repo = parts
|
|
||||||
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
|
|
||||||
if to_column not in bcols.keys(ws_id):
|
|
||||||
return JSONResponse(
|
|
||||||
{"ok": False, "error": "Invalid column"}, status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
move_issue(
|
move_issue(
|
||||||
repo_full_name, issue_number, to_column,
|
repo_full_name, issue_number, to_column,
|
||||||
workspace_id=ws_id, connection_id=conn_id,
|
workspace_id=ws_id, connection_id=conn_id,
|
||||||
@@ -5174,7 +5512,7 @@ async def api_move(
|
|||||||
if from_column != to_column:
|
if from_column != to_column:
|
||||||
log_event(
|
log_event(
|
||||||
repo_full_name, issue_number, "moved",
|
repo_full_name, issue_number, "moved",
|
||||||
from_column or None, to_column,
|
from_column or None, to_column, source=source,
|
||||||
workspace_id=ws_id, connection_id=conn_id,
|
workspace_id=ws_id, connection_id=conn_id,
|
||||||
)
|
)
|
||||||
# Émission Lot 2 (journal du service d'agents) : le titre
|
# Émission Lot 2 (journal du service d'agents) : le titre
|
||||||
@@ -5188,7 +5526,7 @@ async def api_move(
|
|||||||
titre_carte = ""
|
titre_carte = ""
|
||||||
_emettre_changement_colonne(
|
_emettre_changement_colonne(
|
||||||
repo_full_name, issue_number, from_column or None, to_column,
|
repo_full_name, issue_number, from_column or None, to_column,
|
||||||
"ui", title=titre_carte,
|
source, title=titre_carte,
|
||||||
)
|
)
|
||||||
|
|
||||||
result: dict = {"ok": True, "gitea_sync": "skipped"}
|
result: dict = {"ok": True, "gitea_sync": "skipped"}
|
||||||
@@ -5200,14 +5538,14 @@ async def api_move(
|
|||||||
result["gitea_sync"] = "closed"
|
result["gitea_sync"] = "closed"
|
||||||
log_event(
|
log_event(
|
||||||
repo_full_name, issue_number, "closed", to_column="done",
|
repo_full_name, issue_number, "closed", to_column="done",
|
||||||
workspace_id=ws_id, connection_id=conn_id,
|
source=source, workspace_id=ws_id, connection_id=conn_id,
|
||||||
)
|
)
|
||||||
elif from_column == "done":
|
elif from_column == "done":
|
||||||
await client.set_issue_state(owner, repo, issue_number, "open")
|
await client.set_issue_state(owner, repo, issue_number, "open")
|
||||||
result["gitea_sync"] = "reopened"
|
result["gitea_sync"] = "reopened"
|
||||||
log_event(
|
log_event(
|
||||||
repo_full_name, issue_number, "reopened",
|
repo_full_name, issue_number, "reopened",
|
||||||
from_column="done", to_column=to_column,
|
from_column="done", to_column=to_column, source=source,
|
||||||
workspace_id=ws_id, connection_id=conn_id,
|
workspace_id=ws_id, connection_id=conn_id,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
@@ -5253,6 +5591,46 @@ async def api_move(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/move")
|
||||||
|
async def api_move(
|
||||||
|
request: Request,
|
||||||
|
repo_full_name: str = Form(...),
|
||||||
|
issue_number: int = Form(...),
|
||||||
|
to_column: str = Form(...),
|
||||||
|
from_column: str = Form(""),
|
||||||
|
to_order: str = Form(""),
|
||||||
|
from_order: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Move an issue to a new column and persist the resulting order.
|
||||||
|
|
||||||
|
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
|
||||||
|
the issue in Gitea and moving out of "done" reopens it. A failed
|
||||||
|
write-back is reported in the response (gitea_sync="failed") instead
|
||||||
|
of failing the move itself.
|
||||||
|
"""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
parts = _split_full_name(repo_full_name)
|
||||||
|
if parts is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
|
||||||
|
)
|
||||||
|
owner, repo = parts
|
||||||
|
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
|
||||||
|
if to_column not in bcols.keys(ws_id):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Invalid column"}, status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
return await _do_move_card(
|
||||||
|
repo_full_name, issue_number, to_column,
|
||||||
|
from_column=from_column, to_order=to_order,
|
||||||
|
from_order=from_order, client=client, conn_id=conn_id,
|
||||||
|
ws_id=ws_id, source="ui",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/issues")
|
@app.post("/api/issues")
|
||||||
async def api_create_issue(
|
async def api_create_issue(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -5331,6 +5709,17 @@ def _issue_payload(issue: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_payload(asset: dict) -> dict:
|
||||||
|
"""One issue attachment, as the detail drawer consumes it."""
|
||||||
|
return {
|
||||||
|
"id": asset.get("id"),
|
||||||
|
"name": asset.get("name") or "piece-jointe",
|
||||||
|
"size": asset.get("size") or 0,
|
||||||
|
"download_count": asset.get("download_count") or 0,
|
||||||
|
"created_at": asset.get("created_at") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _comment_payload(comment: dict) -> dict:
|
def _comment_payload(comment: dict) -> dict:
|
||||||
user = comment.get("user") or {}
|
user = comment.get("user") or {}
|
||||||
return {
|
return {
|
||||||
@@ -5568,9 +5957,20 @@ async def api_issue_detail(
|
|||||||
{"ok": False, "error": "Issue not found"}, status_code=404
|
{"ok": False, "error": "Issue not found"}, status_code=404
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
payload = _issue_payload(issue)
|
||||||
|
payload["attachments_supported"] = bool(
|
||||||
|
getattr(client, "supports_attachments", False)
|
||||||
|
)
|
||||||
|
payload["assets"] = []
|
||||||
|
if payload["attachments_supported"]:
|
||||||
|
try:
|
||||||
|
assets = await client.list_issue_assets(owner, repo, number)
|
||||||
|
payload["assets"] = [_asset_payload(a) for a in assets]
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("assets failed for %s#%s: %s", owner, number, exc)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"issue": _issue_payload(issue),
|
"issue": payload,
|
||||||
"comments": [_comment_payload(c) for c in comments],
|
"comments": [_comment_payload(c) for c in comments],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5608,6 +6008,183 @@ async def api_issue_comment(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/issues/{owner}/{repo}/{number}/update")
|
||||||
|
async def api_issue_update(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
title: str = Form(""),
|
||||||
|
body: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Edit an issue's title and description on the forge (0.31.0).
|
||||||
|
|
||||||
|
The card drawer finally allows what Gitea's own UI allows:
|
||||||
|
writing the description after creation.
|
||||||
|
"""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
title = title.strip()
|
||||||
|
if not title or len(title) > 255:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Titre requis (255 caractères max)"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if len(body) > 100_000:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Description trop longue (100 000 max)"},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
|
||||||
|
try:
|
||||||
|
issue = await client.update_issue(
|
||||||
|
owner, repo, number, title=title, body=body
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
log_event(
|
||||||
|
f"{owner}/{repo}", number, "edited",
|
||||||
|
workspace_id=ws_id, connection_id=conn_id,
|
||||||
|
)
|
||||||
|
await _publish_board_changed(f"{owner}/{repo}")
|
||||||
|
payload = _issue_payload(issue)
|
||||||
|
payload["attachments_supported"] = bool(
|
||||||
|
getattr(client, "supports_attachments", False)
|
||||||
|
)
|
||||||
|
payload["assets"] = []
|
||||||
|
if payload["attachments_supported"]:
|
||||||
|
try:
|
||||||
|
assets = await client.list_issue_assets(owner, repo, number)
|
||||||
|
payload["assets"] = [_asset_payload(a) for a in assets]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "issue": payload}
|
||||||
|
|
||||||
|
|
||||||
|
MAX_ASSET_BYTES = 25 * 1024 * 1024 # plafond Gitea par défaut
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/issues/{owner}/{repo}/{number}/assets")
|
||||||
|
async def api_issue_asset_upload(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
attachment: UploadFile = File(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Attach a file to an issue (proxied to the forge, 0.31.0)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
if not getattr(client, "supports_attachments", False):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "Pièces jointes non prises en charge par "
|
||||||
|
"l'API de cette forge (GitHub : via son interface web).",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
content = await attachment.read()
|
||||||
|
if not content:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Fichier vide"}, status_code=400
|
||||||
|
)
|
||||||
|
if len(content) > MAX_ASSET_BYTES:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Fichier trop lourd (25 Mio max)"},
|
||||||
|
status_code=413,
|
||||||
|
)
|
||||||
|
filename = (attachment.filename or "piece-jointe").split("/")[-1]
|
||||||
|
filename = filename.split("\\")[-1] or "piece-jointe"
|
||||||
|
try:
|
||||||
|
asset = await client.upload_issue_asset(
|
||||||
|
owner, repo, number, filename, content,
|
||||||
|
attachment.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"La forge répond {exc.response.status_code}"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": True, "asset": _asset_payload(asset)}, status_code=201
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/issues/{owner}/{repo}/{number}/assets/{asset_id}/download")
|
||||||
|
async def api_issue_asset_download(
|
||||||
|
request: Request, owner: str, repo: str, number: int, asset_id: int
|
||||||
|
):
|
||||||
|
"""Download one attachment through Kanbante (token stays home)."""
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
if not getattr(client, "supports_attachments", False):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Non pris en charge par cette forge"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
name = f"piece-jointe-{asset_id}"
|
||||||
|
try:
|
||||||
|
assets = await client.list_issue_assets(owner, repo, number)
|
||||||
|
for asset in assets:
|
||||||
|
if asset.get("id") == asset_id:
|
||||||
|
name = asset.get("name") or name
|
||||||
|
break
|
||||||
|
content, content_type = await client.download_issue_asset(
|
||||||
|
owner, repo, number, asset_id
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Pièce jointe introuvable"},
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
)
|
||||||
|
from fastapi.responses import Response as _Response
|
||||||
|
from urllib.parse import quote as _quote
|
||||||
|
|
||||||
|
return _Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": (
|
||||||
|
f"attachment; filename*=UTF-8''{_quote(name)}"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/issues/{owner}/{repo}/{number}/assets/{asset_id}/delete")
|
||||||
|
async def api_issue_asset_delete(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
asset_id: int,
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Remove one attachment from an issue (0.31.0)."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
|
||||||
|
if not getattr(client, "supports_attachments", False):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Non pris en charge par cette forge"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await client.delete_issue_asset(owner, repo, number, asset_id)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Pièce jointe introuvable"},
|
||||||
|
status_code=exc.response.status_code,
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def api_events(repo: str = "", limit: int = 100):
|
async def api_events(repo: str = "", limit: int = 100):
|
||||||
"""Latest journal entries (all repos, or one via ?repo=owner/name)."""
|
"""Latest journal entries (all repos, or one via ?repo=owner/name)."""
|
||||||
@@ -5752,9 +6329,10 @@ async def api_toggle_issue_label(
|
|||||||
|
|
||||||
# ── MCP server (tools over JSON-RPC) ───────────────────────
|
# ── MCP server (tools over JSON-RPC) ───────────────────────
|
||||||
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
|
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
|
||||||
# add_comment) depuis la 0.26.0, gardées par la même authentification
|
# add_comment) depuis la 0.26.0, move_issue depuis la 0.36.0,
|
||||||
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le
|
# gardées par la même authentification que tout /mcp (session,
|
||||||
# middleware : un jeton n'ouvre jamais l'interface web).
|
# Basic ou jeton d'API kbt_ — voir le middleware : un jeton
|
||||||
|
# n'ouvre jamais l'interface web).
|
||||||
|
|
||||||
MCP_TOOLS = [
|
MCP_TOOLS = [
|
||||||
{
|
{
|
||||||
@@ -5853,15 +6431,77 @@ MCP_TOOLS = [
|
|||||||
"required": ["owner", "repo", "number", "body"],
|
"required": ["owner", "repo", "number", "body"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "move_issue",
|
||||||
|
"description": "Move a card (issue) to another board column. "
|
||||||
|
"column accepts the column key (backlog, todo, in_progress, "
|
||||||
|
"qa, review), the forge label (kanban:review) or the French "
|
||||||
|
"display label (« À faire », « En cours », « Révision »…). "
|
||||||
|
"The kanban:* label on the issue is replaced; its other "
|
||||||
|
"labels are preserved. Moving to done (« Terminé ») is "
|
||||||
|
"REFUSED: that column belongs to the human owner — agents "
|
||||||
|
"stop at review. Write tool — same /mcp authentication as "
|
||||||
|
"the reads (kbt_ API token, session or Basic).",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"owner": {"type": "string"},
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"number": {"type": "integer"},
|
||||||
|
"column": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["owner", "repo", "number", "column"],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _plier_libelle_colonne(valeur: str) -> str:
|
||||||
|
"""Plie un libellé de colonne pour comparaison tolérante :
|
||||||
|
minuscules, accents retirés, emoji et ponctuation éliminés,
|
||||||
|
séparateurs unifiés en espaces (« in_progress »,
|
||||||
|
« in-progress » et « In progress » se rejoignent)."""
|
||||||
|
texte = unicodedata.normalize("NFKD", valeur or "")
|
||||||
|
texte = "".join(c for c in texte if not unicodedata.combining(c))
|
||||||
|
return re.sub(r"[^a-z0-9]+", " ", texte.lower()).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normaliser_colonne_mcp(brut: str, ws_id: int) -> str:
|
||||||
|
"""Résout la colonne visée par l'outil MCP move_issue.
|
||||||
|
|
||||||
|
Accepte la clé (« review »), l'étiquette de forge
|
||||||
|
(« kanban:review », alias d'espace compris) et le libellé
|
||||||
|
français affiché (« Révision », « À faire »…) — les noms
|
||||||
|
d'affichage viennent de la définition des colonnes de
|
||||||
|
l'espace, pas d'une table figée. Lève ValueError, avec la
|
||||||
|
liste des colonnes valides, si rien ne correspond.
|
||||||
|
"""
|
||||||
|
candidats: dict[str, str] = {}
|
||||||
|
for colonne in bcols.get_columns(ws_id):
|
||||||
|
cle = colonne["key"]
|
||||||
|
candidats[_plier_libelle_colonne(cle)] = cle
|
||||||
|
candidats[_plier_libelle_colonne(colonne.get("name") or "")] = cle
|
||||||
|
if colonne.get("label"):
|
||||||
|
candidats[_plier_libelle_colonne(colonne["label"])] = cle
|
||||||
|
for etiquette, cle in bcols.label_lookup(ws_id).items():
|
||||||
|
candidats[_plier_libelle_colonne(etiquette)] = cle
|
||||||
|
candidats["to do"] = "todo"
|
||||||
|
visee = _plier_libelle_colonne(brut)
|
||||||
|
if visee in candidats:
|
||||||
|
return candidats[visee]
|
||||||
|
valides = ", ".join(bcols.keys(ws_id))
|
||||||
|
raise ValueError(
|
||||||
|
f"Colonne inconnue : « {brut} ». Colonnes valides : {valides}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _mcp_call_tool(name: str, args: dict) -> str:
|
async def _mcp_call_tool(name: str, args: dict) -> str:
|
||||||
"""Execute one MCP tool and return text content.
|
"""Execute one MCP tool and return text content.
|
||||||
|
|
||||||
Reads hit the forge's read API; the two write tools
|
Reads hit the forge's read API; the write tools reuse the
|
||||||
(create_issue, add_comment) reuse the existing creation /
|
existing services (creation via _do_capture, comment via the
|
||||||
comment services — never a reimplemented forge call.
|
forge client, move via _do_move_card) — never a reimplemented
|
||||||
|
forge call.
|
||||||
"""
|
"""
|
||||||
if name == "list_projects":
|
if name == "list_projects":
|
||||||
repos = await gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
@@ -5982,6 +6622,65 @@ async def _mcp_call_tool(name: str, args: dict) -> str:
|
|||||||
},
|
},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
if name == "move_issue":
|
||||||
|
owner, repo = args["owner"], args["repo"]
|
||||||
|
number = int(args["number"])
|
||||||
|
full_name = f"{owner}/{repo}"
|
||||||
|
client, conn_id = await _client_for_repo(full_name)
|
||||||
|
ws_id = next(
|
||||||
|
(
|
||||||
|
ws["id"]
|
||||||
|
for ws in models.list_workspaces()
|
||||||
|
if conn_id in models.workspace_connection_ids(ws["id"])
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
target = _normaliser_colonne_mcp(str(args.get("column") or ""), ws_id)
|
||||||
|
if target == "done":
|
||||||
|
# Garde doctrine : Terminé appartient à Bruno dans son
|
||||||
|
# interface — refus avant toute écriture (entrer dans
|
||||||
|
# « done » fermerait l'issue côté forge).
|
||||||
|
raise ValueError(
|
||||||
|
"Colonne refusée : « Terminé » (done) appartient à "
|
||||||
|
"Bruno — les agents s'arrêtent en Révision (review)."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
issue = await client.get_issue(owner, repo, number)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if exc.response is not None and exc.response.status_code == 404:
|
||||||
|
raise ValueError(
|
||||||
|
f"Dépôt ou issue inconnu : {full_name}#{number}"
|
||||||
|
) from exc
|
||||||
|
raise
|
||||||
|
if issue.get("pull_request"):
|
||||||
|
raise ValueError(
|
||||||
|
f"{full_name}#{number} est une pull request, pas une carte."
|
||||||
|
)
|
||||||
|
placement: dict[int, str] = {}
|
||||||
|
for col_name, cards in get_board_state(full_name).items():
|
||||||
|
for card in cards:
|
||||||
|
placement[card["issue_number"]] = col_name
|
||||||
|
from_column = placement.get(number)
|
||||||
|
if from_column is None:
|
||||||
|
from_column = (
|
||||||
|
"done" if issue.get("state") == "closed" else "backlog"
|
||||||
|
)
|
||||||
|
result = await _do_move_card(
|
||||||
|
full_name, number, target,
|
||||||
|
from_column=from_column,
|
||||||
|
client=client, conn_id=conn_id, ws_id=ws_id, source="mcp",
|
||||||
|
)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"repo_full_name": full_name,
|
||||||
|
"number": number,
|
||||||
|
"title": issue.get("title", ""),
|
||||||
|
"from_column": from_column,
|
||||||
|
"column": target,
|
||||||
|
**result,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
raise ValueError(f"Unknown tool: {name}")
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
@@ -5993,7 +6692,7 @@ async def mcp_info():
|
|||||||
"version": APP_VERSION,
|
"version": APP_VERSION,
|
||||||
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
||||||
"read_only": False,
|
"read_only": False,
|
||||||
"write_tools": ["create_issue", "add_comment"],
|
"write_tools": ["create_issue", "add_comment", "move_issue"],
|
||||||
"tools": [t["name"] for t in MCP_TOOLS],
|
"tools": [t["name"] for t in MCP_TOOLS],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6002,8 +6701,9 @@ async def mcp_info():
|
|||||||
async def mcp_endpoint(request: Request):
|
async def mcp_endpoint(request: Request):
|
||||||
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
|
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
|
||||||
|
|
||||||
Reads since 0.5.0; since 0.26.0, two minimal write tools
|
Reads since 0.5.0; since 0.26.0, minimal write tools
|
||||||
(create_issue, add_comment) for the agent service, guarded by
|
(create_issue, add_comment — move_issue since 0.36.0) for the
|
||||||
|
agent service, guarded by
|
||||||
the same /mcp authentication as the reads (session, Basic or
|
the same /mcp authentication as the reads (session, Basic or
|
||||||
kbt_ API token — a token never unlocks the web UI).
|
kbt_ API token — a token never unlocks the web UI).
|
||||||
"""
|
"""
|
||||||
|
|||||||
+1
-1
@@ -603,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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
("digest", "/digest", "🧠", "Digest"),
|
("digest", "/digest", "🧠", "Digest"),
|
||||||
]),
|
]),
|
||||||
("pilotage", "Pilotage", [
|
("pilotage", "Pilotage", [
|
||||||
|
("inflight", "/inflight", "🛩️", "En vol"),
|
||||||
("forecast", "/forecast", "🔮", "Prévisions"),
|
("forecast", "/forecast", "🔮", "Prévisions"),
|
||||||
("health", "/health", "🩺", "Santé"),
|
("health", "/health", "🩺", "Santé"),
|
||||||
("rules", "/rules", "⚙️", "Règles"),
|
("rules", "/rules", "⚙️", "Règles"),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
<label>Nom <input type="text" name="name" value="{{ c.name }}"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
<label>URL de base <input type="text" name="base_url" value="{{ c.base_url }}"></label>
|
||||||
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
<label>Nouveau jeton (vide = inchangé) <input type="password" name="token" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<label>Secret webhook (vide = inchangé) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
<label>Secret webhook (vide = inchangé, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="••••••••"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="admin-actions">
|
<p class="admin-actions">
|
||||||
@@ -68,10 +68,10 @@
|
|||||||
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
<label>Nom <input type="text" name="name" required placeholder="GitHub perso"></label>
|
||||||
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
<label>URL de base <input type="text" name="base_url" required placeholder="https://api.github.com ou https://git.exemple.net"></label>
|
||||||
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
<label>Jeton d'accès <input type="password" name="token" autocomplete="off"></label>
|
||||||
<label>Secret webhook (optionnel) <input type="password" name="hook_secret" autocomplete="off"></label>
|
<label>Secret webhook (optionnel, ou référence coffre:<slug>/<nom>) <input type="password" name="hook_secret" autocomplete="off" placeholder="coffre:<slug>/<nom>"></label>
|
||||||
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
<button type="submit" class="btn-save btn-inline">Ajouter</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="notes-hint">Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
<p class="notes-hint">Secret webhook : une référence <code>coffre:<slug>/<nom></code> est stockée telle quelle (jamais la valeur) et distribuée par ObsiGate à chaque webhook, puis oubliée ; octroi révoqué ou ObsiGate indisponible = webhook rejeté, sans repli. Les jetons d'accès forge, eux, restent chiffrés au repos (voir app/forge_crypto.py) et ne basculent pas encore sur une référence — voir docs/coffre-reference-obsigate.md. Pour GitHub, un jeton personnel (PAT) avec accès aux dépôts visés suffit. Ajoute ensuite la connexion à un espace (Administration → Espaces) pour voir ses projets.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
<title>{{ repo }} — Kanbante</title>
|
<title>{{ repo }} — Kanbante</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||||
<script src="/static/vendor/sortable.min.js"></script>
|
<script src="/static/vendor/sortable.min.js"></script>
|
||||||
<script src="/static/vendor/marked.min.js"></script>
|
<script src="/static/vendor/marked.min.js"></script>
|
||||||
<script src="/static/vendor/purify.min.js"></script>
|
<script src="/static/vendor/purify.min.js"></script>
|
||||||
<script src="/static/js/board.js" defer></script>
|
<script src="/static/js/board.js?v={{ version }}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_header.html" %}
|
{% include "_header.html" %}
|
||||||
@@ -54,10 +54,24 @@
|
|||||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||||
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
|
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
|
||||||
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
|
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
|
||||||
<form id="create-issue-form">
|
<form id="create-issue-form" class="composer">
|
||||||
|
<div class="composer-head">
|
||||||
|
<span class="composer-title">➕ Nouvelle issue</span>
|
||||||
|
<span class="composer-target">arrive dans
|
||||||
|
<strong>{{ column_labels.backlog }}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="composer-main">
|
||||||
<input type="text" id="new-issue-title" maxlength="255"
|
<input type="text" id="new-issue-title" maxlength="255"
|
||||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
placeholder="Titre de l'issue..." required>
|
||||||
<button type="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>
|
||||||
|
|
||||||
@@ -146,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">
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
<label class="dash-tool">Tri
|
<label class="dash-tool">Tri
|
||||||
<select id="dash-sort">
|
<select id="dash-sort">
|
||||||
<option value="default">Par défaut</option>
|
<option value="default">Par défaut</option>
|
||||||
|
<option value="review">🔍 En attente de revue ↓</option>
|
||||||
<option value="updated">Activité Gitea récente</option>
|
<option value="updated">Activité Gitea récente</option>
|
||||||
<option value="activity">Activité kanban récente</option>
|
<option value="activity">Activité kanban récente</option>
|
||||||
<option value="open">Issues ouvertes ↓</option>
|
<option value="open">Issues ouvertes ↓</option>
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
data-full="{{ full }}" data-home="{{ repo.home_tab or '' }}"
|
||||||
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
data-name="{{ repo.name|lower }}" data-owner="{{ repo.owner.login }}"
|
||||||
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
data-updated="{{ repo.updated_at }}" data-open="{{ repo.open_issues_count or 0 }}"
|
||||||
|
data-review="{{ stats.get(full, {}).get('review', 0) if stats else 0 }}"
|
||||||
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
data-activity="{{ repo.activity.last_at if repo.activity else '' }}"
|
||||||
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
data-search="{{ (repo.name ~ ' ' ~ repo.full_name ~ ' ' ~ (repo.description or ''))|lower }}"
|
||||||
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
x-show="search === '' || $el.dataset.search.includes(search.toLowerCase())">
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>En vol partout — Kanbante</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛩️</text></svg>">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_header.html" %}
|
||||||
|
|
||||||
|
<main class="focus-main">
|
||||||
|
<p class="focus-intro">Tout ce qui est <strong>en vol</strong>, tous projets actifs confondus —
|
||||||
|
regroupé par colonne, les cartes les plus anciennes d'abord.</p>
|
||||||
|
|
||||||
|
<div class="inflight-summary">
|
||||||
|
<span class="inflight-total">🛩️ {{ total }} carte{{ 's' if total != 1 }} en vol</span>
|
||||||
|
{% for group in groups %}
|
||||||
|
<span class="col-chip" style="background: {{ column_colors[group.column] }}20; color: {{ column_colors[group.column] }}">
|
||||||
|
{{ column_labels[group.column] }} · {{ group.cards|length }}{% if wip_globals.get(group.column) %} / limite globale {{ wip_globals[group.column] }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not groups %}
|
||||||
|
<p class="focus-empty">🎉 Rien en vol pour le moment — aucun encours, aucune révision en attente.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for group in groups %}
|
||||||
|
<section class="focus-group">
|
||||||
|
<h2>
|
||||||
|
<span class="filter-chip-dot" style="background: {{ column_colors[group.column] }}"></span>
|
||||||
|
{{ column_labels[group.column] }}
|
||||||
|
<span class="badge secondary">{{ group.cards|length }}</span>
|
||||||
|
{% if wip_globals.get(group.column) %}
|
||||||
|
<span class="inflight-wip">limite WIP globale : {{ wip_globals[group.column] }} (somme des projets qui en définissent une)</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<div class="focus-cards">
|
||||||
|
{% for card in group.cards %}
|
||||||
|
<div class="kanban-card focus-card">
|
||||||
|
<div class="card-id">
|
||||||
|
#{{ card.issue.number }}
|
||||||
|
<a class="inflight-repo" href="/board/{{ card.repo }}">{{ card.repo }}</a>
|
||||||
|
{% if card.issue.html_url %}
|
||||||
|
<a href="{{ card.issue.html_url }}" target="_blank" title="Ouvrir dans Gitea">↗</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-title">{{ card.issue.title }}</div>
|
||||||
|
{% if card.issue.labels %}
|
||||||
|
<div class="card-labels">
|
||||||
|
{% for label in card.issue.labels %}
|
||||||
|
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
|
||||||
|
{{ label.name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-meta">
|
||||||
|
{% if card.issue.assignee %}
|
||||||
|
<img src="{{ card.issue.assignee.avatar_url }}" class="avatar" alt="{{ card.issue.assignee.login }}">
|
||||||
|
{% endif %}
|
||||||
|
<span class="age-badge{% if card.age_days >= aging_days * 2 %} stale{% elif card.age_days >= aging_days %} warn{% endif %}"
|
||||||
|
title="Dans cette colonne depuis le {{ card.age_since[:10] }}">⏳ {{ card.age_days }} j dans la colonne</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Kanbante v{{ version }} · en vol partout
|
||||||
|
</footer>
|
||||||
|
<script src="/static/js/live.js" defer></script>
|
||||||
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit" class="login-submit">Se connecter</button>
|
<button type="submit" class="login-submit">Se connecter</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="login-sso" href="https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fkanbante.dracodev.net%2Fauth%2Fdraco">🧭 Se connecter avec ObsiGate (SSO)</a>
|
<a class="login-sso" href="{{ sso_authorize_url }}">🧭 Se connecter avec ObsiGate (SSO)</a>
|
||||||
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
<p class="login-foot">Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+56
-4
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Mes issues — Kanbante</title>
|
<title>Mes issues — Kanbante</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_header.html" %}
|
{% include "_header.html" %}
|
||||||
@@ -15,17 +15,68 @@
|
|||||||
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
||||||
|
|
||||||
|
{% if groups %}
|
||||||
|
<div class="my-filters" id="my-filters">
|
||||||
|
<div class="my-filters-row">
|
||||||
|
<div class="my-search-wrap">
|
||||||
|
<span class="my-search-icon" aria-hidden="true">🔍</span>
|
||||||
|
<input type="search" id="my-search" autocomplete="off"
|
||||||
|
placeholder="Rechercher un titre, un projet, un #numéro, une étiquette… ( / )">
|
||||||
|
</div>
|
||||||
|
<select id="my-repo" title="Filtrer par projet">
|
||||||
|
<option value="">Tous les projets</option>
|
||||||
|
{% for f in repo_facets %}
|
||||||
|
<option value="{{ f.full_name }}">{{ f.full_name }} ({{ f.count }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="my-label" title="Filtrer par étiquette">
|
||||||
|
<option value="">Toutes les étiquettes</option>
|
||||||
|
{% for f in label_facets %}
|
||||||
|
<option value="{{ f.name }}">{{ f.name }} ({{ f.count }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select id="my-sort" title="Trier les résultats">
|
||||||
|
<option value="recent">Activité récente d'abord</option>
|
||||||
|
<option value="oldest">Plus anciennes d'abord</option>
|
||||||
|
<option value="title">Titre (A → Z)</option>
|
||||||
|
<option value="repo">Projet (A → Z)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="my-filters-row my-filters-meta">
|
||||||
|
<div class="my-col-chips" id="my-col-chips" role="group" aria-label="Filtrer par colonne">
|
||||||
|
<button type="button" class="filter-chip on" data-col="">Toutes les colonnes</button>
|
||||||
|
{% for f in column_facets %}
|
||||||
|
<button type="button" class="filter-chip" data-col="{{ f.key }}" aria-pressed="false">
|
||||||
|
<span class="filter-chip-dot" style="background: {{ column_colors[f.key] }}"></span>{{ column_labels[f.key] }} <span class="filter-chip-count">{{ f.count }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<span class="my-count" id="my-count" role="status"></span>
|
||||||
|
<button type="button" id="my-reset" class="btn-sm" hidden>↺ Réinitialiser les filtres</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="empty-state" id="my-empty" hidden>🔍 Aucune issue ne correspond à ces filtres.
|
||||||
|
<button type="button" id="my-empty-reset" class="btn-sm">↺ Réinitialiser les filtres</button>
|
||||||
|
</div>
|
||||||
{% if not groups %}
|
{% if not groups %}
|
||||||
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
<section class="focus-group">
|
<section class="focus-group" data-repo="{{ group.full_name }}">
|
||||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
<span class="badge secondary" data-group-count>{{ group.rows|length }}</span></h2>
|
||||||
<div class="issue-results">
|
<div class="issue-results">
|
||||||
{% for row in group.rows %}
|
{% for row in group.rows %}
|
||||||
{% set issue = row.issue %}
|
{% set issue = row.issue %}
|
||||||
<div class="issue-row">
|
<div class="issue-row"
|
||||||
|
data-column="{{ row.column }}"
|
||||||
|
data-labels="{{ (issue.labels or [])|map(attribute='name')|join(' ') }}"
|
||||||
|
data-updated="{{ issue.updated_at or '' }}"
|
||||||
|
data-title="{{ issue.title }}"
|
||||||
|
data-search="#{{ issue.number }} {{ issue.title }} {{ group.full_name }} {{ (issue.labels or [])|map(attribute='name')|join(' ') }}">
|
||||||
<span class="issue-state">🟢</span>
|
<span class="issue-state">🟢</span>
|
||||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||||
@@ -46,6 +97,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
Kanbante v{{ version }} · mes issues
|
Kanbante v{{ version }} · mes issues
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/static/js/my.js?v={{ version }}" defer></script>
|
||||||
<script src="/static/js/live.js" defer></script>
|
<script src="/static/js/live.js" defer></script>
|
||||||
{% include "_palette.html" %}
|
{% include "_palette.html" %}
|
||||||
<script src="/static/js/palette.js" defer></script>
|
<script src="/static/js/palette.js" defer></script>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ full_name }} — Projet · Kanbante</title>
|
<title>{{ full_name }} — Projet · Kanbante</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
|
<meta name="repo-full-name" content="{{ full_name }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% include "_header.html" %}
|
{% include "_header.html" %}
|
||||||
@@ -51,11 +53,39 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
|
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ms.id %}
|
||||||
|
<details class="ms-edit">
|
||||||
|
<summary>✏️ Modifier / fermer</summary>
|
||||||
|
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
|
||||||
|
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
|
||||||
|
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
|
||||||
|
<span class="ms-hint">vide = retirer l'échéance</span></label>
|
||||||
|
<div class="ms-form-actions">
|
||||||
|
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
|
||||||
|
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
|
||||||
|
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
|
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<details class="ms-edit ms-new">
|
||||||
|
<summary>➕ Nouveau jalon</summary>
|
||||||
|
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
|
||||||
|
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
|
||||||
|
<label>Échéance <input type="date" name="due_on"></label>
|
||||||
|
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
|
||||||
|
<div class="ms-form-actions">
|
||||||
|
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="proj-panel">
|
<section class="proj-panel">
|
||||||
@@ -124,6 +154,7 @@
|
|||||||
|
|
||||||
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
|
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
|
||||||
{% include "_palette.html" %}
|
{% include "_palette.html" %}
|
||||||
|
<script src="/static/js/project.js?v={{ version }}" defer></script>
|
||||||
<script src="/static/js/palette.js" defer></script>
|
<script src="/static/js/palette.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Kanbante — Références coffre ObsiGate (Phase 4, issue ObsiGate #22)
|
||||||
|
|
||||||
|
Patron « même patron, sans variante » de la spec « Coffre de secrets
|
||||||
|
ObsiGate » §12, appliqué à Kanbante sur la branche
|
||||||
|
`feat/coffre-reference-obsigate` (aucun merge, aucune PR dans ce lot).
|
||||||
|
|
||||||
|
## 1. Inventaire honnête des secrets externes (vérifié dans le code, main 0.37.1)
|
||||||
|
|
||||||
|
| Secret | Stockage actuel | Statut dans ce lot |
|
||||||
|
|---|---|---|
|
||||||
|
| Jeton d'accès forge (`forge_connections.token_encrypted`, admin + profil) | **Chiffré Fernet au repos** (`app/forge_crypto.py`, clé `KANBANTE_MASTER_KEY` ou clé générée en base) ; jamais réaffiché (masque `••••••••`) | **Non migré, à raison** : ce n'est pas un secret en clair. Le basculer sur une référence exigerait une distribution *à chaque appel* forge, car le registre met en cache les clients (`ForgeRegistry`) : distribuer une fois à la construction puis garder le client reviendrait à conserver la valeur en mémoire pour la durée de vie de l'app, ce qui violerait le « utilise, puis oublie » de la spec. Reste à faire dédié (voir §5). |
|
||||||
|
| Secret webhook (`forge_connections.webhook_secret`, admin) | **En clair en base** (colonne `TEXT`, saisie via `/admin/connections`) ; repli environnement `KANBANTE_WEBHOOK_SECRET` | **Migré** : une référence `coffre:<slug>/<nom>` saisie dans ce même champ est stockée telle quelle, distribuée à chaque webhook, puis oubliée (§2). Les valeurs historiques en clair restent acceptées (compatibilité, migration opportuniste), sans jamais être réécrites. |
|
||||||
|
| `GITEA_TOKEN`, `KANBANTE_NTFY_TOKEN`, `KANBANTE_OBSIGATE_MCP_TOKEN`, `KANBANTE_CAPTURE_TOKEN`, `AGENT_EVENEMENTS_CLE`, secrets SSO/session | Variables d'environnement d'exploitation uniquement, jamais persistées par l'app, non surchargeables en base (`OVERRIDABLE_SETTINGS` les exclut explicitement) | **Hors périmètre applicatif** : leur bascule éventuelle vers le coffre relève du déploiement (l'opérateur), pas d'un champ de saisie de Kanbante. Le jeton MCP ObsiGate (`KANBANTE_OBSIGATE_MCP_TOKEN`) n'est **pas** réutilisé ici : le consommateur coffre a son propre jeton, conformément à l'issue. |
|
||||||
|
| Mots de passe utilisateurs, jetons API Kanbante | Hashés (`password_hash`, `token_hash`) | Hors périmètre (pas des secrets externes). |
|
||||||
|
|
||||||
|
Conclusion : Kanbante ne stockait qu'**un** secret externe en clair
|
||||||
|
persisté par l'app — le secret webhook — et c'est celui qui est migré.
|
||||||
|
Le client générique ci-dessous reste disponible pour le premier usage
|
||||||
|
réel suivant (jetons forge, voir §5).
|
||||||
|
|
||||||
|
## 2. Ce qui est implémenté
|
||||||
|
|
||||||
|
### `app/coffre_client.py` (nouveau, dépendance : `httpx` uniquement)
|
||||||
|
|
||||||
|
- `est_reference()` / `parse_reference()` : validation du format
|
||||||
|
`coffre:<slug>/<nom>` (préfixe, un seul `/`, slug et nom non vides).
|
||||||
|
- `config_depuis_env()` : `KANBANTE_OBSIGATE_URL` (base ObsiGate, ex.
|
||||||
|
`https://og.dracodev.net`), `KANBANTE_COFFRE_CONSOMMATEUR_TOKEN`
|
||||||
|
(jeton du consommateur « kanbante » enregistré dans ObsiGate),
|
||||||
|
`KANBANTE_COFFRE_TIMEOUT` (défaut 10 s). Absence de l'un des deux
|
||||||
|
premiers → `CoffreNonConfigure`, échec propre.
|
||||||
|
- `distribuer()` / `distribuer_async()` : `POST /api/coffres/distribuer`
|
||||||
|
avec JSON `{reference, contexte_type, contexte_id}` et
|
||||||
|
`Authorization: Bearer <jeton consommateur>` — contrat vérifié dans le
|
||||||
|
routeur ObsiGate (`backend/routers/coffres.py`, main). La valeur
|
||||||
|
retournée n'est **jamais journalisée, jamais persistée, jamais mise
|
||||||
|
en cache**.
|
||||||
|
- `usage_distribue()` / `usage_distribue_async()` : gestionnaires de
|
||||||
|
contexte « un seul usage » — la valeur ne doit pas sortir du bloc.
|
||||||
|
- Erreurs typées, sans repli, avec messages explicites orientant vers
|
||||||
|
ObsiGate (voir §3). Aucune valeur distribuée n'apparaît dans un
|
||||||
|
message, un log ou une réponse HTTP.
|
||||||
|
|
||||||
|
### Webhook (`app/main.py`, `_handle_forge_webhook`)
|
||||||
|
|
||||||
|
- Si `webhook_secret` stocké est une référence : distribution
|
||||||
|
asynchrone à chaque webhook (`contexte_type="webhook"`,
|
||||||
|
`contexte_id=<slug de la connexion>`), HMAC vérifié avec la valeur en
|
||||||
|
mémoire, puis oubli (fin de portée de la fonction).
|
||||||
|
- Échec de distribution → **aucun repli** sur le secret
|
||||||
|
d'environnement ni sur une ancienne valeur (il n'y en a pas de
|
||||||
|
stockée) : `CoffreAccesRefuse` → HTTP 401 « Accès révoqué ou refusé
|
||||||
|
dans ObsiGate : ré-accorder l'octroi… » ; autre `CoffreErreur`
|
||||||
|
(ObsiGate indisponible, consommateur non configuré) → HTTP 503
|
||||||
|
explicite. Le webhook doit être renvoyé après correction côté
|
||||||
|
ObsiGate.
|
||||||
|
- Si la valeur stockée n'est pas une référence : comportement historique
|
||||||
|
inchangé (secret en clair, puis repli environnement).
|
||||||
|
|
||||||
|
### Formulaire admin (`app/main.py` + `app/templates/admin_connections.html`)
|
||||||
|
|
||||||
|
- Le champ « Secret webhook » accepte désormais une référence
|
||||||
|
`coffre:<slug>/<nom>` (création et modification). Une saisie
|
||||||
|
commençant par `coffre:` mais mal formée est **refusée** (redirection
|
||||||
|
d'erreur), pour ne jamais la stocker comme secret en clair historique
|
||||||
|
par accident.
|
||||||
|
- Le champ « Jeton d'accès » est **inchangé** : une référence saisie
|
||||||
|
là serait aujourd'hui chiffrée Fernet puis rejetée par la forge —
|
||||||
|
c'est volontaire tant que la distribution par appel (§5) n'existe pas.
|
||||||
|
Le bandeau du formulaire le signale.
|
||||||
|
|
||||||
|
Aucun changement de schéma n'a été nécessaire : la référence est
|
||||||
|
stockée dans la colonne `webhook_secret` existante, détectée par son
|
||||||
|
préfixe `coffre:` (un secret historique ne peut pas porter ce préfixe
|
||||||
|
sans avoir été saisi comme tel, et ce cas est désormais validé).
|
||||||
|
|
||||||
|
## 3. Correspondance des erreurs ObsiGate
|
||||||
|
|
||||||
|
| Réponse ObsiGate | Erreur Kanbante | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| 401 `consommateur_invalide`, 403 `consommateur_revoque` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder » |
|
||||||
|
| 403 `octroi_absent` / `octroi_revoque` / `octroi_expire`, 410 `secret_expire` | `CoffreAccesRefuse` | Webhook 401, message « ré-accorder / renouveler » |
|
||||||
|
| 400 `reference_invalide` / `contexte_manquant` | `CoffreReferenceInvalide` / `CoffreContexteManquant` | Webhook 503 (erreur de configuration) |
|
||||||
|
| 503 `kek_absente` / `crypto_indisponible`, timeout, connexion refusée, 200 sans valeur | `CoffreIndisponible` | Webhook 503, renvoyer le webhook plus tard |
|
||||||
|
|
||||||
|
Dans tous les cas : pas de valeur en cache, pas de continuation.
|
||||||
|
|
||||||
|
## 4. Tests
|
||||||
|
|
||||||
|
`tests/test_coffre_client.py` (faux endpoint `httpx.MockTransport`,
|
||||||
|
valeurs fictives `TEST-VALUE-XXXX` uniquement, aucune instance réelle) :
|
||||||
|
|
||||||
|
- validation de références (valides / invalides) ;
|
||||||
|
- succès sync + async, avec vérification de la requête (chemin, Bearer,
|
||||||
|
corps `reference` + contexte) ;
|
||||||
|
- refus (octroi révoqué/expiré/absent, consommateur révoqué/invalide,
|
||||||
|
secret expiré) → `CoffreAccesRefuse` avec message explicite ;
|
||||||
|
- indisponibilité (503, erreur réseau, 200 sans valeur) →
|
||||||
|
`CoffreIndisponible` ;
|
||||||
|
- référence invalide / contexte manquant / configuration absente :
|
||||||
|
aucun appel HTTP ;
|
||||||
|
- webhook de bout en bout (TestClient) : octroi révoqué → 401 explicite
|
||||||
|
sans repli ; distribution réussie → HMAC accepté et ligne en base
|
||||||
|
toujours porteuse de la référence, jamais de la valeur.
|
||||||
|
|
||||||
|
## 5. Restes à faire (exigent validation par Bruno sur instance réelle)
|
||||||
|
|
||||||
|
1. **Validation réelle** : enregistrer le consommateur « kanbante »
|
||||||
|
dans ObsiGate, créer un octroi vers un secret webhook de test,
|
||||||
|
configurer `KANBANTE_OBSIGATE_URL` +
|
||||||
|
`KANBANTE_COFFRE_CONSOMMATEUR_TOKEN` sur l'instance Kanbante, saisir
|
||||||
|
la référence dans `/admin/connections`, envoyer un vrai webhook
|
||||||
|
Gitea, vérifier l'acceptation, puis révoquer l'octroi et vérifier
|
||||||
|
le 401 explicite, puis couper ObsiGate et vérifier le 503.
|
||||||
|
2. **Jetons forge par référence** : distribution à chaque appel dans
|
||||||
|
`GiteaClient`/`GitHubClient` (ou client jetable par requête), sans
|
||||||
|
passer par le cache `ForgeRegistry` ; seulement alors, accepter une
|
||||||
|
référence dans le champ « Jeton d'accès » et documenter la migration
|
||||||
|
des connexions existantes (chiffrées Fernet → références).
|
||||||
|
3. **Migration opportuniste des secrets webhook historiques** : proposer
|
||||||
|
en admin la conversion d'un secret en clair existant vers une
|
||||||
|
référence (l'admin crée le secret + l'octroi dans ObsiGate, puis
|
||||||
|
remplace la valeur par la référence) — aucun outil automatique ne
|
||||||
|
copie une valeur réelle vers ObsiGate dans ce lot.
|
||||||
|
4. **CI complète** : la suite n'a été exécutée que localement dans le
|
||||||
|
sandbox (voir rapport) ; le run CI Gitea de la branche reste à
|
||||||
|
observer avant toute PR.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "kanbante"
|
name = "kanbante"
|
||||||
version = "0.30.0"
|
version = "0.37.1"
|
||||||
description = "Kanban project manager integrated with Gitea"
|
description = "Kanban project manager integrated with Gitea"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1683,3 +1683,70 @@ body.sidebar-collapsed .ws-switch { display: none; }
|
|||||||
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
|
||||||
border: 1px solid var(--border, #333); white-space: nowrap; }
|
border: 1px solid var(--border, #333); white-space: nowrap; }
|
||||||
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
|
||||||
|
|
||||||
|
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
|
||||||
|
.kanban-card { cursor: pointer; }
|
||||||
|
.detail-actions { margin: 10px 0; }
|
||||||
|
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
|
||||||
|
#detail-edit-form textarea { width: 100%; }
|
||||||
|
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
|
||||||
|
.asset-row a { text-decoration: none; font-weight: 600; }
|
||||||
|
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
|
||||||
|
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ── Compositeur de nouvelle issue (0.31.1) ── */
|
||||||
|
/* La toolbar impose display:flex à ses formulaires : le
|
||||||
|
compositeur est une carte, en bloc et pleine largeur. */
|
||||||
|
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
|
||||||
|
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
|
||||||
|
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
||||||
|
.composer-title { font-weight: 700; font-size: 0.95rem; }
|
||||||
|
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
|
||||||
|
.composer-main { display: flex; gap: 8px; }
|
||||||
|
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
|
||||||
|
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||||
|
.composer-desc { margin-top: 10px; }
|
||||||
|
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
|
||||||
|
.composer-desc summary::-webkit-details-marker { display: none; }
|
||||||
|
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
|
||||||
|
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
|
||||||
|
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
|
||||||
|
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
|
||||||
|
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||||
|
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
|
||||||
|
|
||||||
|
/* ── Mes issues : barre de filtres (0.33.0) ── */
|
||||||
|
.my-filters { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 12px 0 18px; }
|
||||||
|
.my-filters-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.my-filters-row + .my-filters-row { margin-top: 10px; }
|
||||||
|
.my-search-wrap { position: relative; flex: 1 1 280px; }
|
||||||
|
.my-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); opacity: 0.65; pointer-events: none; }
|
||||||
|
.my-search-wrap input[type="search"] { width: 100%; background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; padding: 9px 14px 9px 38px; color: inherit; font-size: 0.92rem; }
|
||||||
|
.my-search-wrap input[type="search"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
|
||||||
|
.my-filters select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 10px; color: inherit; font-size: 0.9rem; }
|
||||||
|
.my-filters select:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.my-col-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-right: auto; }
|
||||||
|
.filter-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--surface-2); border: 1px solid var(--border); color: inherit; border-radius: 999px; padding: 5px 12px; font-size: 0.85rem; cursor: pointer; transition: border-color 0.15s, background 0.15s; }
|
||||||
|
.filter-chip:hover { border-color: var(--border-strong); }
|
||||||
|
.filter-chip.on { background: rgba(124, 92, 252, 0.18); border-color: var(--accent); font-weight: 600; }
|
||||||
|
.filter-chip-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.filter-chip-count { opacity: 0.7; font-size: 0.78rem; }
|
||||||
|
.my-count { color: var(--muted, #9aa3b5); font-size: 0.88rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── En vol partout (0.34.0, issue #24) ── */
|
||||||
|
.inflight-summary { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 10px 0 18px; }
|
||||||
|
.inflight-total { font-weight: 700; margin-right: 4px; }
|
||||||
|
.inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */
|
||||||
|
.ms-edit { margin-top: 8px; }
|
||||||
|
.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; }
|
||||||
|
.ms-edit summary::-webkit-details-marker { display: none; }
|
||||||
|
.ms-edit summary:hover { text-decoration: underline; }
|
||||||
|
.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; }
|
||||||
|
.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; }
|
||||||
|
.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; }
|
||||||
|
.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; }
|
||||||
|
.ms-form-actions { display: flex; gap: 8px; }
|
||||||
|
|||||||
+152
-5
@@ -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';
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
function cardKey(card, mode) {
|
function cardKey(card, mode) {
|
||||||
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
if (mode === 'name') return (card.getAttribute('data-name') || '').toLowerCase();
|
||||||
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
if (mode === 'open') return -(parseInt(card.getAttribute('data-open') || '0', 10));
|
||||||
|
if (mode === 'review') return -(parseInt(card.getAttribute('data-review') || '0', 10));
|
||||||
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
if (mode === 'updated') return card.getAttribute('data-updated') || '';
|
||||||
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
if (mode === 'activity') return card.getAttribute('data-activity') || '';
|
||||||
return null; // 'default' : ordre serveur, on ne touche à rien
|
return null; // 'default' : ordre serveur, on ne touche à rien
|
||||||
@@ -52,6 +53,13 @@
|
|||||||
if (isNaN(tb)) return -1;
|
if (isNaN(tb)) return -1;
|
||||||
return tb - ta;
|
return tb - ta;
|
||||||
}
|
}
|
||||||
|
// Modes numériques (open, review) : les clés sont
|
||||||
|
// des compteurs niés (-n) — ka - kb donne donc le
|
||||||
|
// plus grand compteur en premier, comme l'annonce
|
||||||
|
// le « ↓ » des options. (Avant 0.37.0, la
|
||||||
|
// comparaison générique ci-dessous inversait ces
|
||||||
|
// deux tris : ils ressortaient croissants.)
|
||||||
|
if (mode === 'open' || mode === 'review') return ka - kb;
|
||||||
if (ka < kb) return mode === 'name' ? -1 : 1;
|
if (ka < kb) return mode === 'name' ? -1 : 1;
|
||||||
if (ka > kb) return mode === 'name' ? 1 : -1;
|
if (ka > kb) return mode === 'name' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
* (persistée), thème clair/sombre aligné sur l'app hôte
|
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||||
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||||
* Bouton « Console » (barre des discussions) : ouvre la console web
|
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||||
* du même service (agent.dracodev.net) DANS la page, en surimpression
|
* du même service (adresse injectée par l'hôte) DANS la page, en surimpression
|
||||||
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||||
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||||
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||||
@@ -32,13 +32,18 @@
|
|||||||
* configuré / exécuteur non configuré sont affichés tels quels. */
|
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var API = "https://agent.dracodev.net";
|
/* Adresse du service : injectée par l'app hôte (gabarit
|
||||||
|
* _draco_bar.html, window.DRACO_PANNEAU_CONFIG.api, issu de la
|
||||||
|
* configuration de l'hôte — AGENT_URL). Aucun nom public en dur
|
||||||
|
* ici : sans injection, le panneau se déclare non configuré. */
|
||||||
|
var API = (window.DRACO_PANNEAU_CONFIG && window.DRACO_PANNEAU_CONFIG.api) || "";
|
||||||
var convId = null;
|
var convId = null;
|
||||||
var convKind = null; // 'principale' | 'parallele'
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
var convTitre = "";
|
var convTitre = "";
|
||||||
var etatConnu = false;
|
var etatConnu = false;
|
||||||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
var titreEl = null, tiroirEl = null;
|
var titreEl = null, tiroirEl = null;
|
||||||
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
function domaine() {
|
function domaine() {
|
||||||
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||||
@@ -251,7 +256,7 @@
|
|||||||
' <span class="d-titre">Discussion principale</span>' +
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-discussions" hidden></div>' +
|
'<div class="draco-discussions" hidden></div>' +
|
||||||
'<div class="draco-panneau-outils">' +
|
'<div class="draco-panneau-outils">' +
|
||||||
@@ -264,6 +269,7 @@
|
|||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="submit">Envoyer</button></form>' +
|
||||||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||||
|
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
@@ -272,6 +278,7 @@
|
|||||||
sendBtn = root.querySelector("button[type=submit]");
|
sendBtn = root.querySelector("button[type=submit]");
|
||||||
titreEl = root.querySelector(".d-titre");
|
titreEl = root.querySelector(".d-titre");
|
||||||
tiroirEl = root.querySelector(".draco-discussions");
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
root.querySelector(".fermer").addEventListener("click", fermer);
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||||
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||||
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||||
@@ -326,8 +333,43 @@
|
|||||||
titreEl.title = titreEl.textContent;
|
titreEl.title = titreEl.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Version du service : lue en direct dans la santé de
|
||||||
|
* l'agent (GET /healthz → champ « version », la même source que
|
||||||
|
* la console) à l'origine que le panneau connaît déjà. Cache de
|
||||||
|
* session (5 min), rafraîchie à l'ouverture du panneau, au même
|
||||||
|
* rythme que la vérification d'identité. Jamais de valeur codée
|
||||||
|
* en dur : source injoignable ou réponse illisible = la ligne
|
||||||
|
* reste masquée — pas de fausse version, pas d'erreur visible. */
|
||||||
|
function afficherVersion() {
|
||||||
|
if (!versionEl) return;
|
||||||
|
if (versionConnue) {
|
||||||
|
versionEl.textContent = "Service d'agents v" + versionConnue;
|
||||||
|
versionEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function chargerVersion() {
|
||||||
|
if (!API || !versionEl) return;
|
||||||
|
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
|
||||||
|
fetch(API + "/healthz")
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
|
||||||
|
versionConnue = d.version;
|
||||||
|
versionMaj = Date.now();
|
||||||
|
afficherVersion();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* service injoignable : rien d'affiché */ });
|
||||||
|
}
|
||||||
|
|
||||||
function verifierIdentite() {
|
function verifierIdentite() {
|
||||||
if (etatConnu) return;
|
if (etatConnu) return;
|
||||||
|
if (!API) {
|
||||||
|
majEtat("err", "Panneau Spark non configuré");
|
||||||
|
ajouterMsg("sys", "Adresse du service d'agents absente : l'app hôte ne l'a pas injectée (DRACO_PANNEAU_CONFIG / AGENT_URL).");
|
||||||
|
etatConnu = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
fetch(API + "/api/v1/me", { credentials: "include" })
|
fetch(API + "/api/v1/me", { credentials: "include" })
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
@@ -346,7 +388,7 @@
|
|||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
majEtat("err", "Service d'agents injoignable");
|
majEtat("err", "Service d'agents injoignable");
|
||||||
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
ajouterMsg("sys", "Le service d'agents (" + API + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,6 +844,7 @@
|
|||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
|
chargerVersion();
|
||||||
setTimeout(function () { inputEl.focus(); }, 300);
|
setTimeout(function () { inputEl.focus(); }, 300);
|
||||||
}
|
}
|
||||||
function fermer() { if (root) root.classList.remove("open"); }
|
function fermer() { if (root) root.classList.remove("open"); }
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
// Mes issues — filtrage instantané côté client (0.33.0).
|
||||||
|
// Aucune requête réseau : les lignes portent leurs facettes en
|
||||||
|
// attributs data-*, le moteur ne fait que comparer des chaînes
|
||||||
|
// normalisées. Les filtres structurels (projet, colonnes,
|
||||||
|
// étiquette, tri) persistent en localStorage ; la recherche
|
||||||
|
// texte, elle, est éphémère.
|
||||||
|
(() => {
|
||||||
|
const search = document.getElementById('my-search');
|
||||||
|
if (!search) return; // page sans résultats : rien à filtrer
|
||||||
|
const repoSel = document.getElementById('my-repo');
|
||||||
|
const labelSel = document.getElementById('my-label');
|
||||||
|
const sortSel = document.getElementById('my-sort');
|
||||||
|
const chipsBox = document.getElementById('my-col-chips');
|
||||||
|
const countEl = document.getElementById('my-count');
|
||||||
|
const resetBtn = document.getElementById('my-reset');
|
||||||
|
const emptyBox = document.getElementById('my-empty');
|
||||||
|
const emptyReset = document.getElementById('my-empty-reset');
|
||||||
|
const STORE_KEY = 'kanbante-my-filters';
|
||||||
|
|
||||||
|
const norm = (s) => (s || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '');
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
q: '',
|
||||||
|
repo: '',
|
||||||
|
label: '',
|
||||||
|
cols: new Set(),
|
||||||
|
sort: 'recent',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Persistance des filtres structurels ────────────────
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || '{}');
|
||||||
|
if (saved.repo) state.repo = saved.repo;
|
||||||
|
if (saved.label) state.label = saved.label;
|
||||||
|
if (Array.isArray(saved.cols)) state.cols = new Set(saved.cols);
|
||||||
|
if (saved.sort) state.sort = saved.sort;
|
||||||
|
} catch { /* stockage indisponible : état par défaut */ }
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORE_KEY, JSON.stringify({
|
||||||
|
repo: state.repo,
|
||||||
|
label: state.label,
|
||||||
|
cols: [...state.cols],
|
||||||
|
sort: state.sort,
|
||||||
|
}));
|
||||||
|
} catch { /* sans gravité */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refléter l'état restauré dans les contrôles.
|
||||||
|
if (state.repo) repoSel.value = state.repo;
|
||||||
|
if (state.label) labelSel.value = state.label;
|
||||||
|
sortSel.value = state.sort;
|
||||||
|
// Un filtre restauré peut ne plus exister (projet disparu) :
|
||||||
|
// dans ce cas le select retombe à « tous » tout seul.
|
||||||
|
if (repoSel.value !== state.repo) state.repo = repoSel.value;
|
||||||
|
if (labelSel.value !== state.label) state.label = labelSel.value;
|
||||||
|
|
||||||
|
const groups = [...document.querySelectorAll('.focus-group')];
|
||||||
|
const allRows = [...document.querySelectorAll('.issue-row')];
|
||||||
|
allRows.forEach((row) => {
|
||||||
|
row._search = norm(row.dataset.search);
|
||||||
|
row._labels = norm(row.dataset.labels).split(' ').filter(Boolean);
|
||||||
|
row._title = norm(row.dataset.title);
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncChips() {
|
||||||
|
chipsBox.querySelectorAll('.filter-chip').forEach((chip) => {
|
||||||
|
const col = chip.dataset.col;
|
||||||
|
const on = col === '' ? state.cols.size === 0 : state.cols.has(col);
|
||||||
|
chip.classList.toggle('on', on);
|
||||||
|
if (col !== '') chip.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows() {
|
||||||
|
const mode = state.sort;
|
||||||
|
if (mode === 'repo') {
|
||||||
|
const main = document.querySelector('.focus-main');
|
||||||
|
[...groups].sort((a, b) =>
|
||||||
|
a.dataset.repo.localeCompare(b.dataset.repo)
|
||||||
|
).forEach((g) => main.appendChild(g));
|
||||||
|
}
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const box = group.querySelector('.issue-results');
|
||||||
|
const rows = [...box.querySelectorAll('.issue-row')];
|
||||||
|
const by = {
|
||||||
|
recent: (a, b) => (b.dataset.updated || '').localeCompare(a.dataset.updated || ''),
|
||||||
|
oldest: (a, b) => (a.dataset.updated || '').localeCompare(b.dataset.updated || ''),
|
||||||
|
title: (a, b) => a._title.localeCompare(b._title),
|
||||||
|
repo: (a, b) => a._title.localeCompare(b._title),
|
||||||
|
}[mode];
|
||||||
|
rows.sort(by).forEach((r) => box.appendChild(r));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
const q = norm(state.q).trim();
|
||||||
|
let visible = 0;
|
||||||
|
groups.forEach((group) => {
|
||||||
|
let groupVisible = 0;
|
||||||
|
group.querySelectorAll('.issue-row').forEach((row) => {
|
||||||
|
const okText = !q || row._search.includes(q);
|
||||||
|
const okRepo = !state.repo || group.dataset.repo === state.repo;
|
||||||
|
const okCol = state.cols.size === 0 || state.cols.has(row.dataset.column);
|
||||||
|
const okLabel = !state.label
|
||||||
|
|| row._labels.includes(norm(state.label));
|
||||||
|
const ok = okText && okRepo && okCol && okLabel;
|
||||||
|
row.hidden = !ok;
|
||||||
|
if (ok) groupVisible++;
|
||||||
|
});
|
||||||
|
group.hidden = groupVisible === 0;
|
||||||
|
const badge = group.querySelector('[data-group-count]');
|
||||||
|
if (badge) badge.textContent = groupVisible;
|
||||||
|
visible += groupVisible;
|
||||||
|
});
|
||||||
|
countEl.textContent = visible === allRows.length
|
||||||
|
? `${visible} issue${visible > 1 ? 's' : ''}`
|
||||||
|
: `${visible} sur ${allRows.length} issues`;
|
||||||
|
const filtering = q || state.repo || state.label
|
||||||
|
|| state.cols.size > 0;
|
||||||
|
resetBtn.hidden = !filtering;
|
||||||
|
emptyBox.hidden = visible !== 0;
|
||||||
|
syncChips();
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
state.q = '';
|
||||||
|
search.value = '';
|
||||||
|
state.repo = '';
|
||||||
|
repoSel.value = '';
|
||||||
|
state.label = '';
|
||||||
|
labelSel.value = '';
|
||||||
|
state.cols.clear();
|
||||||
|
sortSel.value = 'recent';
|
||||||
|
state.sort = 'recent';
|
||||||
|
sortRows();
|
||||||
|
apply();
|
||||||
|
search.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Événements ─────────────────────────────────────────
|
||||||
|
let raf = 0;
|
||||||
|
search.addEventListener('input', () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
state.q = search.value;
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
search.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
search.value = '';
|
||||||
|
state.q = '';
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
repoSel.addEventListener('change', () => { state.repo = repoSel.value; apply(); });
|
||||||
|
labelSel.addEventListener('change', () => { state.label = labelSel.value; apply(); });
|
||||||
|
sortSel.addEventListener('change', () => {
|
||||||
|
state.sort = sortSel.value;
|
||||||
|
sortRows();
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
chipsBox.addEventListener('click', (e) => {
|
||||||
|
const chip = e.target.closest('.filter-chip');
|
||||||
|
if (!chip) return;
|
||||||
|
const col = chip.dataset.col;
|
||||||
|
if (col === '') state.cols.clear();
|
||||||
|
else if (state.cols.has(col)) state.cols.delete(col);
|
||||||
|
else state.cols.add(col);
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
resetBtn.addEventListener('click', resetAll);
|
||||||
|
emptyReset?.addEventListener('click', resetAll);
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== '/') return;
|
||||||
|
const tag = document.activeElement?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
|
e.preventDefault();
|
||||||
|
search.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
sortRows();
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
var ACTIONS = [
|
var ACTIONS = [
|
||||||
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||||
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
||||||
|
{ icon: '🛩️', label: 'En vol partout — encours tous projets, par colonne', href: '/inflight' },
|
||||||
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
|
{ icon: '🌅', label: "Focus du jour — les 3 cartes d'aujourd'hui", href: '/today' },
|
||||||
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
{ icon: '🔮', label: 'Prévisions — on finit quand ?', href: '/forecast' },
|
||||||
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
|
{ icon: '🗣️', label: 'Standup automatique — hier, aujourd\'hui, blocages', href: '/standup' },
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Page Projet — écritures de jalons (0.35.0, issue #26).
|
||||||
|
// Les formulaires data-ms-form sont interceptés : POST vers
|
||||||
|
// l'API Kanbante (qui écrit dans la forge), puis rechargement
|
||||||
|
// pour refléter l'état réel.
|
||||||
|
(() => {
|
||||||
|
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
document.querySelectorAll('form[data-ms-form]').forEach((form) => {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const confirmMsg = form.dataset.confirm;
|
||||||
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
|
const fd = new FormData(form);
|
||||||
|
fd.append('csrf_token', csrf);
|
||||||
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(form.dataset.action, { method: 'POST', body: fd });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
window.alert('❌ ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
+97
-2
@@ -187,6 +187,52 @@ class FakeGitea:
|
|||||||
"404", request=request, response=_httpx.Response(404, request=request)
|
"404", request=request, response=_httpx.Response(404, request=request)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
supports_attachments = True
|
||||||
|
|
||||||
|
async def update_issue(self, owner, repo, number, title=None, body=None):
|
||||||
|
issue = await self.get_issue(owner, repo, number)
|
||||||
|
if title is not None:
|
||||||
|
issue["title"] = title
|
||||||
|
if body is not None:
|
||||||
|
issue["body"] = body
|
||||||
|
return issue
|
||||||
|
|
||||||
|
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
|
||||||
|
@property
|
||||||
|
def _assets(self):
|
||||||
|
return self.__dict__.setdefault("assets_store", {})
|
||||||
|
|
||||||
|
async def list_issue_assets(self, owner, repo, number):
|
||||||
|
return list(self._assets.get((f"{owner}/{repo}", number), []))
|
||||||
|
|
||||||
|
async def upload_issue_asset(
|
||||||
|
self, owner, repo, number, filename, content, content_type="application/octet-stream"
|
||||||
|
):
|
||||||
|
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
|
||||||
|
asset = {
|
||||||
|
"id": 700 + len(store) + number,
|
||||||
|
"name": filename,
|
||||||
|
"size": len(content),
|
||||||
|
"download_count": 0,
|
||||||
|
"created_at": "2026-10-23T10:00:00Z",
|
||||||
|
"_content": content,
|
||||||
|
"_type": content_type,
|
||||||
|
}
|
||||||
|
store.append(asset)
|
||||||
|
return {k: v for k, v in asset.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
async def delete_issue_asset(self, owner, repo, number, asset_id):
|
||||||
|
store = self._assets.get((f"{owner}/{repo}", number), [])
|
||||||
|
self._assets[(f"{owner}/{repo}", number)] = [
|
||||||
|
a for a in store if a["id"] != asset_id
|
||||||
|
]
|
||||||
|
|
||||||
|
async def download_issue_asset(self, owner, repo, number, asset_id):
|
||||||
|
for asset in self._assets.get((f"{owner}/{repo}", number), []):
|
||||||
|
if asset["id"] == asset_id:
|
||||||
|
return asset["_content"], asset["_type"]
|
||||||
|
raise AssertionError("asset introuvable dans le faux")
|
||||||
|
|
||||||
async def get_issue_comments(self, owner, repo, number):
|
async def get_issue_comments(self, owner, repo, number):
|
||||||
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
||||||
|
|
||||||
@@ -313,14 +359,63 @@ class FakeGitea:
|
|||||||
else:
|
else:
|
||||||
self.starred.discard(f"{owner}/{repo}")
|
self.starred.discard(f"{owner}/{repo}")
|
||||||
|
|
||||||
async def get_milestones(self, owner, repo, state="open"):
|
@property
|
||||||
return [
|
def _milestones(self):
|
||||||
|
return self.__dict__.setdefault(
|
||||||
|
"milestones_store",
|
||||||
|
{
|
||||||
|
"Projets/demo": [
|
||||||
{
|
{
|
||||||
"id": 1, "title": "Sprint 1",
|
"id": 1, "title": "Sprint 1",
|
||||||
|
"description": "",
|
||||||
"due_on": "2026-10-20T00:00:00Z",
|
"due_on": "2026-10-20T00:00:00Z",
|
||||||
|
"state": "open",
|
||||||
"open_issues": 1, "closed_issues": 1,
|
"open_issues": 1, "closed_issues": 1,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_milestones(self, owner, repo, state="open"):
|
||||||
|
items = self._milestones.get(f"{owner}/{repo}", [])
|
||||||
|
return [
|
||||||
|
dict(m) for m in items
|
||||||
|
if state == "all" or m.get("state", "open") == state
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_milestone(
|
||||||
|
self, owner, repo, title, description="", due_on=None
|
||||||
|
):
|
||||||
|
store = self._milestones.setdefault(f"{owner}/{repo}", [])
|
||||||
|
milestone = {
|
||||||
|
"id": 100 + len(store),
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"due_on": due_on,
|
||||||
|
"state": "open",
|
||||||
|
"open_issues": 0, "closed_issues": 0,
|
||||||
|
}
|
||||||
|
store.append(milestone)
|
||||||
|
return dict(milestone)
|
||||||
|
|
||||||
|
async def update_milestone(
|
||||||
|
self, owner, repo, milestone_id, title=None, description=None,
|
||||||
|
due_on=None, clear_due=False, state=None,
|
||||||
|
):
|
||||||
|
for m in self._milestones.get(f"{owner}/{repo}", []):
|
||||||
|
if m["id"] == milestone_id:
|
||||||
|
if title is not None:
|
||||||
|
m["title"] = title
|
||||||
|
if description is not None:
|
||||||
|
m["description"] = description
|
||||||
|
if clear_due:
|
||||||
|
m["due_on"] = None
|
||||||
|
elif due_on:
|
||||||
|
m["due_on"] = due_on
|
||||||
|
if state is not None:
|
||||||
|
m["state"] = state
|
||||||
|
return dict(m)
|
||||||
|
raise AssertionError("jalon introuvable dans le faux")
|
||||||
|
|
||||||
async def create_issue(self, owner, repo, title, body=""):
|
async def create_issue(self, owner, repo, title, body=""):
|
||||||
self._next_number += 1
|
self._next_number += 1
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Tests du client de distribution coffre ObsiGate (Phase 4, issue ObsiGate #22).
|
||||||
|
|
||||||
|
Aucune instance ObsiGate réelle n'est contactée : le « faux endpoint » est
|
||||||
|
un ``httpx.MockTransport`` local, et toutes les valeurs distribuées dans
|
||||||
|
ces tests sont fictives (``TEST-VALUE-XXXX``), jamais des secrets réels.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.main as main
|
||||||
|
from app import models
|
||||||
|
from app.coffre_client import (
|
||||||
|
CoffreAccesRefuse,
|
||||||
|
CoffreConfig,
|
||||||
|
CoffreContexteManquant,
|
||||||
|
CoffreIndisponible,
|
||||||
|
CoffreNonConfigure,
|
||||||
|
CoffreReferenceInvalide,
|
||||||
|
distribuer,
|
||||||
|
distribuer_async,
|
||||||
|
est_reference,
|
||||||
|
parse_reference,
|
||||||
|
usage_distribue,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuration fictive, réservée aux tests.
|
||||||
|
CONFIG_TEST = CoffreConfig(
|
||||||
|
base_url="https://obsigate.test",
|
||||||
|
jeton_consommateur="TEST-CONSUMER-TOKEN",
|
||||||
|
)
|
||||||
|
REFERENCE_TEST = "coffre:TEST-SLUG/TEST-NOM"
|
||||||
|
VALEUR_TEST = "TEST-VALUE-XXXX"
|
||||||
|
|
||||||
|
|
||||||
|
def _transport_faux(statut=200, corps=None, verifier=None):
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if verifier is not None:
|
||||||
|
verifier(request)
|
||||||
|
return httpx.Response(statut, json=corps if corps is not None else {})
|
||||||
|
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _verifier_requete(request: httpx.Request):
|
||||||
|
assert request.url.path == "/api/coffres/distribuer"
|
||||||
|
assert request.headers["Authorization"] == "Bearer TEST-CONSUMER-TOKEN"
|
||||||
|
corps = json.loads(request.content.decode())
|
||||||
|
assert corps["reference"] == REFERENCE_TEST
|
||||||
|
assert corps["contexte_type"] == "webhook"
|
||||||
|
assert corps["contexte_id"] == "gitea"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reference, attendu",
|
||||||
|
[
|
||||||
|
("coffre:personnel/gitea", True),
|
||||||
|
("coffre:TEST-SLUG/TEST-NOM", True),
|
||||||
|
("coffre:a/b/c", False), # un seul « / » après le préfixe
|
||||||
|
("coffre:/nom", False),
|
||||||
|
("coffre:slug/", False),
|
||||||
|
("coffre:slug", False),
|
||||||
|
("gitea", False),
|
||||||
|
("", False),
|
||||||
|
(None, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reference_validation(reference, attendu):
|
||||||
|
assert est_reference(reference) is attendu
|
||||||
|
if attendu:
|
||||||
|
assert parse_reference(reference)[0]
|
||||||
|
else:
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
parse_reference(reference or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_succes_sync():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_distribuer_succes_async():
|
||||||
|
transport = _transport_faux(
|
||||||
|
200,
|
||||||
|
{"reference": REFERENCE_TEST, "valeur": VALEUR_TEST, "version": 1},
|
||||||
|
verifier=_verifier_requete,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await distribuer_async(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport
|
||||||
|
)
|
||||||
|
== VALEUR_TEST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"statut, detail, message_attendu",
|
||||||
|
[
|
||||||
|
(403, "octroi_revoque", "ré-accorder"),
|
||||||
|
(403, "octroi_expire", "renouveler"),
|
||||||
|
(403, "octroi_absent", "octroi"),
|
||||||
|
(401, "consommateur_revoque", "ré-accorder"),
|
||||||
|
(401, "consommateur_invalide", "invalide"),
|
||||||
|
(410, "secret_expire", "expiré"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_distribuer_refus_sans_repli(statut, detail, message_attendu):
|
||||||
|
transport = _transport_faux(statut, {"detail": detail})
|
||||||
|
with pytest.raises(CoffreAccesRefuse) as excinfo:
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
assert message_attendu in str(excinfo.value)
|
||||||
|
assert VALEUR_TEST not in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_indisponible_503_et_reseau():
|
||||||
|
transport = _transport_faux(503, {"detail": "kek_absente"})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
def _panne(request: httpx.Request):
|
||||||
|
raise httpx.ConnectError("faux ObsiGate injoignable", request=request)
|
||||||
|
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_panne),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_sans_valeur_malgre_200():
|
||||||
|
transport = _transport_faux(200, {"reference": REFERENCE_TEST})
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea", config=CONFIG_TEST, transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_reference_invalide_sans_appel():
|
||||||
|
def _interdit(request: httpx.Request):
|
||||||
|
raise AssertionError("aucun appel HTTP ne doit partir")
|
||||||
|
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
distribuer("valeur-brute", "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=httpx.MockTransport(_interdit))
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_contexte_manquant_sans_appel():
|
||||||
|
with pytest.raises(CoffreContexteManquant):
|
||||||
|
distribuer(REFERENCE_TEST, "", "gitea", config=CONFIG_TEST)
|
||||||
|
|
||||||
|
|
||||||
|
def test_distribuer_non_configure(monkeypatch):
|
||||||
|
monkeypatch.delenv("KANBANTE_OBSIGATE_URL", raising=False)
|
||||||
|
monkeypatch.delenv("KANBANTE_COFFRE_CONSOMMATEUR_TOKEN", raising=False)
|
||||||
|
with pytest.raises(CoffreNonConfigure):
|
||||||
|
distribuer(REFERENCE_TEST, "webhook", "gitea")
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_distribue_ne_conserve_que_le_resultat():
|
||||||
|
transport = _transport_faux(200, {"valeur": VALEUR_TEST})
|
||||||
|
# Le bloc ne sert qu'à l'usage : ici, dériver un HMAC, comme le ferait
|
||||||
|
# la vérification d'un webhook. Seul le résultat de l'usage est
|
||||||
|
# conservé après le bloc, jamais la valeur distribuée.
|
||||||
|
with usage_distribue(
|
||||||
|
REFERENCE_TEST, "webhook", "gitea",
|
||||||
|
config=CONFIG_TEST, transport=transport,
|
||||||
|
) as valeur:
|
||||||
|
signature = hmac.new(valeur.encode(), b"corps-test", hashlib.sha256).hexdigest()
|
||||||
|
assert len(signature) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_octroi_revoque_sans_repli(client, monkeypatch):
|
||||||
|
"""Référence webhook + octroi révoqué -> 401 explicite, sans repli."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook", "gitea", "Coffre webhook", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refuse(*args, **kwargs):
|
||||||
|
raise CoffreAccesRefuse("Accès révoqué dans ObsiGate pour cette référence.")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _refuse)
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook",
|
||||||
|
content=b"{}",
|
||||||
|
headers={"x-gitea-signature": "0" * 64},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 401
|
||||||
|
assert "ObsiGate" in reponse.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_reference_distribuee_puis_oubliee(client, monkeypatch):
|
||||||
|
"""Référence webhook distribuée -> HMAC vérifié, valeur non persistée."""
|
||||||
|
models.create_connection(
|
||||||
|
"coffre-webhook-ok", "gitea", "Coffre webhook OK", "https://gitea.test",
|
||||||
|
token_encrypted="", account_login="", webhook_secret=REFERENCE_TEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _distribue(reference, contexte_type, contexte_id):
|
||||||
|
assert reference == REFERENCE_TEST
|
||||||
|
assert contexte_type == "webhook"
|
||||||
|
assert contexte_id == "coffre-webhook-ok"
|
||||||
|
return VALEUR_TEST
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.coffre_client, "distribuer_async", _distribue)
|
||||||
|
corps = b"{}"
|
||||||
|
signature = hmac.new(VALEUR_TEST.encode(), corps, hashlib.sha256).hexdigest()
|
||||||
|
reponse = client.post(
|
||||||
|
"/webhooks/coffre-webhook-ok",
|
||||||
|
content=corps,
|
||||||
|
headers={"x-gitea-signature": signature},
|
||||||
|
)
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
# La valeur distribuée n'a jamais été persistée : la ligne stocke
|
||||||
|
# toujours la référence, jamais la valeur fictive.
|
||||||
|
ligne = models.get_connection_by_slug("coffre-webhook-ok")
|
||||||
|
assert ligne["webhook_secret"] == REFERENCE_TEST
|
||||||
|
assert VALEUR_TEST not in (ligne["webhook_secret"] or "")
|
||||||
+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
|
||||||
|
|||||||
+132
-5
@@ -1,9 +1,11 @@
|
|||||||
"""Écritures MCP minimales (0.26.0) : create_issue + add_comment.
|
"""Écritures MCP minimales : create_issue + add_comment (0.26.0),
|
||||||
|
move_issue (0.36.0).
|
||||||
|
|
||||||
Le service d'agents doit pouvoir proposer des cartes et commenter
|
Le service d'agents doit pouvoir proposer des cartes, commenter
|
||||||
via Kanbante, sans passer par l'interface web. Les deux outils
|
et déplacer des cartes via Kanbante, sans passer par l'interface
|
||||||
réutilisent les services existants (création via _do_capture →
|
web. Les trois outils réutilisent les services existants
|
||||||
client de forge, commentaire via client.create_comment) et sont
|
(création via _do_capture → client de forge, commentaire via
|
||||||
|
client.create_comment, déplacement via _do_move_card) et sont
|
||||||
gardés par la même authentification que les lectures /mcp :
|
gardés par la même authentification que les lectures /mcp :
|
||||||
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
|
session, Basic ou jeton d'API kbt_ — jamais l'interface web.
|
||||||
"""
|
"""
|
||||||
@@ -145,6 +147,131 @@ def test_mcp_write_tools_are_listed(client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Déplacement de carte (0.36.0) ──────────────────────────
|
||||||
|
|
||||||
|
def _seed_carte_todo(fake_gitea, number=1):
|
||||||
|
"""Place la carte dans « À faire », étiquette kanban:todo
|
||||||
|
miroir posée (la carte garde son étiquette « bug »)."""
|
||||||
|
etiquette = {"id": 950, "name": "kanban:todo", "color": "3b82f6"}
|
||||||
|
fake_gitea.repo_labels["Projets/demo"]["kanban:todo"] = etiquette
|
||||||
|
issue = next(
|
||||||
|
i for i in fake_gitea.issues["Projets/demo"]
|
||||||
|
if i["number"] == number
|
||||||
|
)
|
||||||
|
issue["labels"].append(etiquette)
|
||||||
|
models.move_issue("Projets/demo", number, "todo")
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_move_issue_moves_card_and_replaces_label(client, fake_gitea):
|
||||||
|
issue = _seed_carte_todo(fake_gitea)
|
||||||
|
r = _call(client, "move_issue", {
|
||||||
|
"owner": "Projets", "repo": "demo", "number": 1,
|
||||||
|
"column": "review",
|
||||||
|
})
|
||||||
|
result, text = _result_text(r)
|
||||||
|
assert result["isError"] is False
|
||||||
|
payload = json.loads(text)
|
||||||
|
assert payload["from_column"] == "todo"
|
||||||
|
assert payload["column"] == "review"
|
||||||
|
assert payload["label_sync"] == "ok"
|
||||||
|
# Étiquette kanban remplacée, les autres préservées.
|
||||||
|
noms = [l["name"] for l in issue["labels"]]
|
||||||
|
assert "kanban:review" in noms
|
||||||
|
assert "kanban:todo" not in noms
|
||||||
|
assert "bug" in noms
|
||||||
|
assert ("remove", 1, "kanban:todo") in fake_gitea.label_ops
|
||||||
|
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
|
||||||
|
# Le board suit le déplacement (lecture MCP relue).
|
||||||
|
board = json.loads(_result_text(_call(
|
||||||
|
client, "get_board", {"owner": "Projets", "repo": "demo"}
|
||||||
|
))[1])
|
||||||
|
assert any(c["number"] == 1 for c in board["review"])
|
||||||
|
assert not any(c["number"] == 1 for c in board["todo"])
|
||||||
|
# Journal : déplacement tracé avec la source « mcp ».
|
||||||
|
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||||
|
moved = [e for e in events if e["event_type"] == "moved"]
|
||||||
|
assert moved and moved[0]["source"] == "mcp"
|
||||||
|
assert moved[0]["from_column"] == "todo"
|
||||||
|
assert moved[0]["to_column"] == "review"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_move_issue_accepts_french_labels(client, fake_gitea):
|
||||||
|
_seed_carte_todo(fake_gitea)
|
||||||
|
for libelle, cle in (
|
||||||
|
("En cours", "in_progress"),
|
||||||
|
("Révision", "review"),
|
||||||
|
("kanban:qa", "qa"),
|
||||||
|
("À faire", "todo"),
|
||||||
|
):
|
||||||
|
r = _call(client, "move_issue", {
|
||||||
|
"owner": "Projets", "repo": "demo", "number": 1,
|
||||||
|
"column": libelle,
|
||||||
|
})
|
||||||
|
result, text = _result_text(r)
|
||||||
|
assert result["isError"] is False, (libelle, text)
|
||||||
|
assert json.loads(text)["column"] == cle
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_move_issue_refuses_done_before_any_write(client, fake_gitea):
|
||||||
|
_seed_carte_todo(fake_gitea)
|
||||||
|
for libelle in ("done", "Terminé"):
|
||||||
|
r = _call(client, "move_issue", {
|
||||||
|
"owner": "Projets", "repo": "demo", "number": 1,
|
||||||
|
"column": libelle,
|
||||||
|
})
|
||||||
|
result, text = _result_text(r)
|
||||||
|
assert result["isError"] is True
|
||||||
|
assert "Terminé" in text
|
||||||
|
# Aucune écriture : ni étiquette, ni état, ni journal.
|
||||||
|
assert fake_gitea.label_ops == []
|
||||||
|
assert fake_gitea.state_changes == []
|
||||||
|
events = models.get_events(repo_full_name="Projets/demo", limit=10)
|
||||||
|
assert not [e for e in events if e["event_type"] == "moved"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_move_issue_errors(client, fake_gitea):
|
||||||
|
# Colonne inconnue : erreur propre, avec les colonnes valides.
|
||||||
|
r = _call(client, "move_issue", {
|
||||||
|
"owner": "Projets", "repo": "demo", "number": 1,
|
||||||
|
"column": "nimporte",
|
||||||
|
})
|
||||||
|
result, text = _result_text(r)
|
||||||
|
assert result["isError"] is True
|
||||||
|
assert "Colonnes valides" in text and "review" in text
|
||||||
|
# Issue inconnue, puis dépôt inconnu.
|
||||||
|
for arguments in (
|
||||||
|
{"owner": "Projets", "repo": "demo", "number": 999,
|
||||||
|
"column": "review"},
|
||||||
|
{"owner": "Projets", "repo": "inexistant", "number": 1,
|
||||||
|
"column": "review"},
|
||||||
|
):
|
||||||
|
result, text = _result_text(_call(client, "move_issue", arguments))
|
||||||
|
assert result["isError"] is True
|
||||||
|
assert "inconnu" in text
|
||||||
|
# Une pull request n'est pas une carte.
|
||||||
|
r = _call(client, "move_issue", {
|
||||||
|
"owner": "Projets", "repo": "demo", "number": 2,
|
||||||
|
"column": "review",
|
||||||
|
})
|
||||||
|
result, text = _result_text(r)
|
||||||
|
assert result["isError"] is True
|
||||||
|
assert "pull request" in text
|
||||||
|
assert fake_gitea.label_ops == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_move_issue_is_listed(client):
|
||||||
|
r = client.post("/mcp", json={
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
|
||||||
|
})
|
||||||
|
tools = {t["name"]: t for t in r.json()["result"]["tools"]}
|
||||||
|
assert tools["move_issue"]["inputSchema"]["required"] == [
|
||||||
|
"owner", "repo", "number", "column",
|
||||||
|
]
|
||||||
|
info = client.get("/mcp").json()
|
||||||
|
assert "move_issue" in info["write_tools"]
|
||||||
|
|
||||||
|
|
||||||
# ── Garde par jeton kbt_ (auth active) ─────────────────────
|
# ── Garde par jeton kbt_ (auth active) ─────────────────────
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -97,11 +97,15 @@ def test_metrics_counts_and_cycle_time():
|
|||||||
|
|
||||||
|
|
||||||
def test_column_entry_times_ignores_comments():
|
def test_column_entry_times_ignores_comments():
|
||||||
|
# Capturer l'horodatage UNE fois : _days_ago() réévalué à
|
||||||
|
# l'assertion basculait d'une seconde une fois sur ~N
|
||||||
|
# (échec CI 2333 du 2026-10-26, run jumeau vert).
|
||||||
|
entry = _days_ago(3)
|
||||||
models.log_event("a/b", 1, "moved", to_column="todo",
|
models.log_event("a/b", 1, "moved", to_column="todo",
|
||||||
created_at=_days_ago(3))
|
created_at=entry)
|
||||||
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
|
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
|
||||||
times = models.column_entry_times("a/b")
|
times = models.column_entry_times("a/b")
|
||||||
assert times[1] == _days_ago(3)
|
assert times[1] == entry
|
||||||
|
|
||||||
|
|
||||||
# ── Panneau détail ─────────────────────────────────────────
|
# ── Panneau détail ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Palier 22 (0.33.0): barre de filtres de la page Mes issues —
|
||||||
|
facettes serveur, lignes annotées, moteur client chargé."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_page_has_filter_bar_and_facets(client):
|
||||||
|
r = client.get("/my")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# La barre et ses contrôles.
|
||||||
|
assert 'id="my-filters"' in r.text
|
||||||
|
assert 'id="my-search"' in r.text
|
||||||
|
assert 'id="my-repo"' in r.text
|
||||||
|
assert 'id="my-label"' in r.text
|
||||||
|
assert 'id="my-sort"' in r.text
|
||||||
|
assert 'id="my-col-chips"' in r.text
|
||||||
|
assert 'id="my-count"' in r.text
|
||||||
|
assert 'id="my-reset"' in r.text
|
||||||
|
# Facettes avec compteurs (démo : 1 issue assignée,
|
||||||
|
# étiquette « bug »).
|
||||||
|
assert "Projets/demo (1)" in r.text
|
||||||
|
assert "bug (1)" in r.text
|
||||||
|
# Le moteur de filtrage est chargé, versionné.
|
||||||
|
assert "/static/js/my.js?v=" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_rows_carry_filter_attributes(client):
|
||||||
|
r = client.get("/my")
|
||||||
|
# Attributs data-* consommés par le moteur client.
|
||||||
|
assert 'data-column="backlog"' in r.text
|
||||||
|
assert 'data-search="#1 Tâche un Projets/demo bug"' in r.text
|
||||||
|
assert 'data-updated="2026-09-02T09:00:00Z"' in r.text
|
||||||
|
assert 'data-repo="Projets/demo"' in r.text
|
||||||
|
# Chips de colonnes présentes, avec compteurs.
|
||||||
|
assert 'data-col="backlog"' in r.text
|
||||||
|
assert 'data-col=""' in r.text # « Toutes les colonnes »
|
||||||
|
|
||||||
|
|
||||||
|
def test_my_empty_state_present_but_hidden(client):
|
||||||
|
r = client.get("/my")
|
||||||
|
assert 'id="my-empty" hidden' in r.text
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Palier 23 (0.34.0, issue #24): vue « En vol partout » —
|
||||||
|
l'encours tous projets, regroupé par colonne, plus vieilles
|
||||||
|
cartes d'abord."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
|
||||||
|
def _days_ago(days: int) -> str:
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_groups_by_column_with_project(client):
|
||||||
|
models.move_issue("Projets/demo", 5, "in_progress")
|
||||||
|
models.move_issue("Projets/demo", 1, "review")
|
||||||
|
r = client.get("/inflight")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "2 cartes en vol" in r.text
|
||||||
|
assert "Tâche focus" in r.text
|
||||||
|
assert "Tâche un" in r.text
|
||||||
|
# Le projet est indiqué sur la carte.
|
||||||
|
assert "Projets/demo" in r.text
|
||||||
|
# Groupes par colonne, pas par projet.
|
||||||
|
assert "🚧 En cours" in r.text
|
||||||
|
assert "🔍 Révision" in r.text
|
||||||
|
# L'âge dans la colonne est affiché.
|
||||||
|
assert "dans la colonne" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_excludes_backlog_and_todo(client):
|
||||||
|
models.move_issue("Projets/demo", 5, "todo")
|
||||||
|
r = client.get("/inflight")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Tâche focus" not in r.text
|
||||||
|
assert "Rien en vol" in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_oldest_first(client):
|
||||||
|
# Deux cartes en cours : entrée il y a 10 j et il y a 2 j.
|
||||||
|
models.move_issue("Projets/demo", 5, "in_progress")
|
||||||
|
models.move_issue("Projets/demo", 1, "in_progress")
|
||||||
|
models.log_event(
|
||||||
|
"Projets/demo", 5, "moved", to_column="in_progress",
|
||||||
|
created_at=_days_ago(10),
|
||||||
|
)
|
||||||
|
models.log_event(
|
||||||
|
"Projets/demo", 1, "moved", to_column="in_progress",
|
||||||
|
created_at=_days_ago(2),
|
||||||
|
)
|
||||||
|
r = client.get("/inflight")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.text.index("Tâche focus") < r.text.index("Tâche un")
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_closed_issue_disappears(client, fake_gitea):
|
||||||
|
models.move_issue("Projets/demo", 5, "in_progress")
|
||||||
|
issue = next(
|
||||||
|
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
|
||||||
|
)
|
||||||
|
issue["state"] = "closed"
|
||||||
|
r = client.get("/inflight")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Tâche focus" not in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_inflight_nav_and_page_chrome(client):
|
||||||
|
r = client.get("/inflight")
|
||||||
|
assert 'href="/inflight"' in r.text # menu latéral Pilotage
|
||||||
|
assert "En vol partout" in r.text
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Palier 24 (0.35.0, issue #26): jalons en écriture —
|
||||||
|
créer, modifier (titre/échéance), fermer, depuis Kanbante."""
|
||||||
|
|
||||||
|
from tests.conftest import csrf_from
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestone_create_via_api(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/repos/Projets/demo/milestones",
|
||||||
|
data={
|
||||||
|
"title": "Version 2.0",
|
||||||
|
"description": "Le gros morceau",
|
||||||
|
"due_on": "2027-01-31",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
payload = r.json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["milestone"]["title"] == "Version 2.0"
|
||||||
|
assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z"
|
||||||
|
titles = [
|
||||||
|
m["title"] for m in fake_gitea._milestones["Projets/demo"]
|
||||||
|
]
|
||||||
|
assert "Version 2.0" in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestone_create_rejects_bad_date(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
avant = len(fake_gitea._milestones["Projets/demo"])
|
||||||
|
r = client.post(
|
||||||
|
"/api/repos/Projets/demo/milestones",
|
||||||
|
data={
|
||||||
|
"title": "Jalon invalide",
|
||||||
|
"due_on": "31/01/2027",
|
||||||
|
"csrf_token": token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
# Rien d'écrit côté forge.
|
||||||
|
assert len(fake_gitea._milestones["Projets/demo"]) == avant
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestone_update_title_and_clear_due(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/repos/Projets/demo/milestones/1/update",
|
||||||
|
data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
sprint = fake_gitea._milestones["Projets/demo"][0]
|
||||||
|
assert sprint["title"] == "Sprint 1 — prolongé"
|
||||||
|
assert sprint["due_on"] is None # échéance retirée
|
||||||
|
|
||||||
|
|
||||||
|
def test_milestone_close(client, fake_gitea):
|
||||||
|
token = csrf_from(client)
|
||||||
|
r = client.post(
|
||||||
|
"/api/repos/Projets/demo/milestones/1/close",
|
||||||
|
data={"csrf_token": token},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed"
|
||||||
|
# La page Projet ne liste plus le jalon fermé parmi les ouverts.
|
||||||
|
page = client.get("/project/Projets/demo")
|
||||||
|
assert "Version" in page.text # la page répond toujours
|
||||||
|
assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_page_has_milestone_management(client):
|
||||||
|
page = client.get("/project/Projets/demo")
|
||||||
|
assert page.status_code == 200
|
||||||
|
assert "Nouveau jalon" in page.text
|
||||||
|
assert "Modifier / fermer" in page.text
|
||||||
|
assert 'name="due_on"' in page.text
|
||||||
|
assert "/static/js/project.js?v=" in page.text
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Palier 25 (0.37.0): tri « En attente de revue » du dashboard —
|
||||||
|
les projets (section Épinglés incluse) s'ordonnent par le nombre
|
||||||
|
de cartes dans la colonne de validation."""
|
||||||
|
|
||||||
|
from app import models
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_sort_offers_review_mode(client):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert '<option value="review">🔍 En attente de revue ↓</option>' in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_cards_carry_review_count(client):
|
||||||
|
# Aucune carte en révision au départ.
|
||||||
|
r = client.get("/")
|
||||||
|
assert 'data-review="0"' in r.text
|
||||||
|
# Une carte passe en Révision : le compteur de SA carte monte.
|
||||||
|
models.move_issue("Projets/demo", 5, "review")
|
||||||
|
models.move_issue("Projets/demo", 1, "review")
|
||||||
|
r = client.get("/")
|
||||||
|
assert 'data-review="2"' in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_js_knows_review_mode(client):
|
||||||
|
# Le moteur de tri partagé est servi tel quel : le mode
|
||||||
|
# 'review' doit y figurer (tri décroissant sur data-review).
|
||||||
|
r = client.get("/static/js/dashboard.js")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "mode === 'review'" in r.text
|
||||||
|
assert "data-review" in r.text
|
||||||
|
# Le sens : les modes numeriques trient en ka - kb (cles
|
||||||
|
# niees = plus grand compteur d\'abord). Sans cette
|
||||||
|
# branche, le comparateur generique inversait le tri :
|
||||||
|
# bug attrape en navigateur reel sur le tri historique.
|
||||||
|
assert "return ka - kb" in r.text
|
||||||
@@ -121,7 +121,7 @@ def test_mcp_initialize_and_tools_list(client):
|
|||||||
assert names == [
|
assert names == [
|
||||||
"list_projects", "get_board", "get_issue", "get_focus",
|
"list_projects", "get_board", "get_issue", "get_focus",
|
||||||
"search_issues", "get_activity", "get_metrics",
|
"search_issues", "get_activity", "get_metrics",
|
||||||
"create_issue", "add_comment",
|
"create_issue", "add_comment", "move_issue",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -167,8 +167,11 @@ def test_mcp_info_endpoint(client):
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
|
# 0.26.0 : écritures minimales ajoutées — le serveur n'est
|
||||||
# plus en lecture seule, et il le déclare honnêtement.
|
# plus en lecture seule, et il le déclare honnêtement.
|
||||||
|
# 0.36.0 : move_issue s'ajoute aux écritures déclarées.
|
||||||
assert r.json()["read_only"] is False
|
assert r.json()["read_only"] is False
|
||||||
assert r.json()["write_tools"] == ["create_issue", "add_comment"]
|
assert r.json()["write_tools"] == [
|
||||||
|
"create_issue", "add_comment", "move_issue",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ── Board : gestion des étiquettes ────────────────────────
|
# ── Board : gestion des étiquettes ────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user