Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1743c8c101 | ||
|
|
411c0d2934 | ||
|
|
6e71b87d09 | ||
|
|
0417d0593f | ||
|
|
39b751df56 | ||
|
|
1cc27bd4fb | ||
|
|
0c280b3c7e | ||
|
|
395ab6e14c | ||
|
|
05b8c506e0 | ||
|
|
943ecd6a6b | ||
|
|
af59417d07 | ||
|
|
a358c45d73 | ||
|
|
b7ddc10971 | ||
|
|
542ed70c1a | ||
|
|
52c41ead91 | ||
|
|
f40d4c817b | ||
|
|
71881514c9 | ||
|
|
7d2faf027d | ||
|
|
b99e87ccca | ||
|
|
6887d631ea | ||
|
|
a788db7b1f | ||
|
|
2d89db8213 | ||
|
|
45283f0f8a | ||
|
|
dbda3bcf5a | ||
|
|
23e63c7f6e | ||
|
|
3cac4fe9c3 | ||
|
|
f1dd921e63 | ||
|
|
9bf24da04e | ||
|
|
a6d2ed9eae | ||
|
|
2060ca949d |
@@ -17,4 +17,4 @@ jobs:
|
||||
- name: Compile check
|
||||
run: python -m py_compile app/*.py
|
||||
- name: Tests
|
||||
run: pytest -q
|
||||
run: python -m pytest -q
|
||||
|
||||
+223
@@ -1,5 +1,228 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Palette de commandes** (issue #10) : `Ctrl+K` / `Cmd+K` (ou le
|
||||
bouton ⌨️ dans la navigation) ouvre sur toutes les pages une
|
||||
palette façon Linear/Raycast — actions de navigation, tous les
|
||||
projets (filtrage local instantané) et recherche globale d'issues
|
||||
annotées de leur colonne (nouvel endpoint `GET /api/palette?q=`).
|
||||
Navigation entièrement au clavier (↑↓, Entrée, Échap).
|
||||
- **Digest hebdo vers ObsiGate** (issue #13) : le résumé markdown
|
||||
de la semaine (issues fermées enrichies de leurs titres, cartes en
|
||||
cours/QA/révision, compteurs du journal) est composé
|
||||
automatiquement le jour et l'heure choisis
|
||||
(`KANBANTE_DIGEST_WEEKDAY` / `KANBANTE_DIGEST_TIME`) avec
|
||||
notification ntfy — mais **jamais livré sans approbation** :
|
||||
l'envoi se fait d'un clic sur `/digest`, vers le serveur MCP
|
||||
d'ObsiGate (client MCP intégré : initialize + tools/call ; URL,
|
||||
jeton, nom d'outil et gabarit JSON d'arguments configurables ;
|
||||
bouton « Tester la connexion » qui liste les outils du serveur)
|
||||
ou vers un webhook générique (`KANBANTE_DIGEST_WEBHOOK_URL`,
|
||||
JSON `{title, markdown, week}`).
|
||||
|
||||
11 tests ajoutés (121 au total).
|
||||
|
||||
## 0.7.0 — 2026-10-11
|
||||
|
||||
### Nouveauté
|
||||
- **Capture éclair** (issue #12 de la feuille de route) : trois
|
||||
entrées, un seul geste — transformer un message en issue placée
|
||||
sur le board (événement `created`, source `capture`) :
|
||||
- `POST /api/capture` : endpoint machine-à-machine protégé par un
|
||||
**jeton de capture dédié** (`KANBANTE_CAPTURE_TOKEN`, en-tête
|
||||
`X-Capture-Token` ou Bearer ; distinct du jeton Gitea, révocable
|
||||
seul ; désactivé en 503 tant qu'il n'est pas défini ; exempté
|
||||
d'auth Basic et de CSRF). JSON `{title, body, repo, column}` ou
|
||||
texte libre (première ligne = titre). Dépôt par défaut :
|
||||
`KANBANTE_CAPTURE_REPO`.
|
||||
- **Écoute ntfy** : si `KANBANTE_CAPTURE_NTFY_TOPIC` est défini,
|
||||
un abonné en tâche de fond (flux JSON du topic, reconnexion avec
|
||||
recul, curseur persisté dans `kv_store` — aucun rejeu au
|
||||
redémarrage) transforme chaque message en issue. Les messages
|
||||
tagués `kanbante` sont ignorés (pas d'auto-ingestion).
|
||||
- Page **`/capture`** : formulaire de saisie rapide, statut de
|
||||
configuration et exemples prêts à copier (curl, ntfy). Lien
|
||||
« ⚡ Capture » dans la navigation de toutes les pages.
|
||||
|
||||
12 tests ajoutés (110 au total).
|
||||
|
||||
## 0.6.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Colonne QA** entre « En cours » et « Révision » (🧪, étiquette
|
||||
`kanban:qa`) : une étape de validation finale avant la révision.
|
||||
Le flux complet devient Backlog → À faire → En cours → QA →
|
||||
Révision → Terminé ; la vue Focus inclut les cartes en QA.
|
||||
- **Wallboard** (issue #14 de la feuille de route) : `GET /wall`,
|
||||
affichage plein écran pensé pour un écran d'atelier — rotation
|
||||
automatique (30 s) entre les projets actifs, compteurs des six
|
||||
colonnes, cartes en vol (En cours / QA / Révision / À faire) avec
|
||||
âge, fermées du jour, horloge ; rafraîchissement en direct via le
|
||||
flux SSE. `?repo=Proprietaire/depot` fige un seul projet. Lien
|
||||
« 🖥️ Wallboard » ajouté à la navigation de toutes les pages.
|
||||
- **Réconciliation par étiquettes** : si l'étiquette `kanban:*` d'une
|
||||
issue ouverte diverge de sa colonne enregistrée (modifiée dans
|
||||
Gitea ou par un autre outil), le board et la vue Focus adoptent la
|
||||
colonne de l'étiquette et journalisent le déplacement (source
|
||||
`gitea`). Règles : exactement une étiquette `kanban:*` fait foi ;
|
||||
zéro ou plusieurs = l'état local gagne ; inactif si
|
||||
`KANBANTE_SYNC_LABELS=0`. Déplacer une carte depuis Gitea devient
|
||||
donc possible.
|
||||
|
||||
10 tests ajoutés (98 au total).
|
||||
|
||||
## 0.5.2 — 2026-10-11
|
||||
|
||||
### Correctif
|
||||
- **L'indice « Glissez une carte ici » restait affiché après un
|
||||
dépôt dans une colonne vide** : il est rendu par le serveur pour
|
||||
les colonnes vides au chargement, et le glisser-déposer ne le
|
||||
retirait jamais du DOM. `refreshEmptyHints()` recalcule désormais
|
||||
l'indice d'après le contenu réel de chaque colonne après tout
|
||||
déplacement (et après un retour arrière en cas d'échec) : retiré
|
||||
dès qu'une carte est présente, rétabli quand la dernière carte
|
||||
part. SortableJS est en outre restreint aux seules cartes
|
||||
(`draggable: '.kanban-card'`), l'indice n'est plus un élément
|
||||
déplaçable parasite.
|
||||
|
||||
## 0.5.1 — 2026-10-11
|
||||
|
||||
### Correctif critique
|
||||
- **Les boards étaient vides contre un vrai Gitea** : l'API Gitea
|
||||
(1.27.3 vérifié) renvoie la clé `"pull_request": null` sur les
|
||||
issues ordinaires ; le filtre `_is_issue` testait la *présence* de
|
||||
la clé (`"pull_request" not in item`), donc chaque vraie issue
|
||||
était classée « pull request » et écartée. Board, vue Focus et
|
||||
briefing ne montraient aucune carte (le dashboard, qui lit les
|
||||
compteurs des dépôts, semblait normal). Corrigé : le filtre teste
|
||||
la *valeur* (`not item.get("pull_request")`).
|
||||
- Le faux Gitea des tests omettait la clé, ce qui masquait le bug :
|
||||
il reproduit désormais fidèlement le payload réel (clé toujours
|
||||
présente, `null` sur les issues), plus 4 tests de régression dédiés
|
||||
(88 tests au total). Smoke test réel : le board `bruno/flowdeck`
|
||||
passe de 0 à 6 cartes, dont l'issue de test « test d'ajout ! ».
|
||||
|
||||
## 0.5.0 — 2026-10-11
|
||||
|
||||
« Palier 3 » (points 7 à 9) + board enrichi.
|
||||
|
||||
### Ajouts
|
||||
- **Recherche globale** (`/search`) : recherche d'issues dans tous les
|
||||
projets via l'API Gitea, résultats groupés par projet avec la colonne
|
||||
Kanban de chaque issue. **Mes issues** (`/my`) : les issues ouvertes
|
||||
assignées à l'utilisateur du jeton, tous projets confondus.
|
||||
- **Briefing quotidien** : page `/briefing` (encours, activité 24 h,
|
||||
cartes vieillissantes, milestones proches), `GET /api/briefing`, et
|
||||
envoi **ntfy** manuel (`POST /api/briefing/send`) ou automatique à
|
||||
`KANBANTE_BRIEFING_TIME` via un planificateur interne (mémoire
|
||||
d'envoi en base, aucun doublon après redémarrage).
|
||||
- **Serveur MCP** (lecture seule) : JSON-RPC 2.0 sur `POST /mcp`
|
||||
(initialize, tools/list, tools/call, ping) avec 7 outils —
|
||||
`list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Aucune écriture,
|
||||
conformément au principe « lectures d'abord ». `GET /mcp` décrit le
|
||||
serveur.
|
||||
- **Gestion des étiquettes sur le board** : modale de gestion
|
||||
(lister / créer avec palette de couleurs / supprimer les étiquettes
|
||||
du dépôt dans Gitea) et, dans le panneau détail, ajout / retrait
|
||||
d'étiquettes sur l'issue en un clic, avec mise à jour immédiate de
|
||||
la carte. API : `GET|POST /api/repos/{o}/{r}/labels`,
|
||||
`DELETE /api/repos/{o}/{r}/labels/{id}`,
|
||||
`POST /api/issues/{o}/{r}/{n}/labels`.
|
||||
|
||||
### Améliorations
|
||||
- **Panneau Notes du projet** : repliable (bouton ⤢, état mémorisé par
|
||||
projet), **largeur ajustable** par glisser (mémorisée), et
|
||||
**sauvegarde automatique** après 1,2 s sans frappe avec indicateur
|
||||
d'état (le bouton manuel reste). Le formulaire quitte HTMX pour le
|
||||
même `board.js` que le reste du board.
|
||||
- Navigation unifiée enrichie sur toutes les pages : Mes issues,
|
||||
Briefing, Recherche.
|
||||
|
||||
### Tests
|
||||
- 19 tests ajoutés (84 au total) : recherche et mes issues, briefing
|
||||
(page, API, envoi ntfy, format), protocole MCP, API étiquettes,
|
||||
marqueurs du board. Un doublon de déclaration JS attrapé par
|
||||
`node --check` avant livraison.
|
||||
|
||||
## 0.4.0 — 2026-10-11
|
||||
|
||||
« Palier 2 » : temps réel et visible dans Gitea — plus une refonte
|
||||
complète de l'interface.
|
||||
|
||||
### Ajouts
|
||||
- **Webhooks Gitea + rafraîchissement en direct** : `POST /webhooks/gitea`
|
||||
(signature HMAC-SHA256 vérifiée quand `KANBANTE_WEBHOOK_SECRET` est
|
||||
défini) invalide le cache de lecture, applique les fermetures /
|
||||
réouvertures faites dans Gitea (idempotent : l'écho de nos propres
|
||||
appels ne double jamais le journal) et pousse un événement SSE sur
|
||||
`GET /api/stream`. Le navigateur se recharge en douceur quand c'est
|
||||
sûr (onglet visible, pas de panneau ouvert, pas de saisie en cours,
|
||||
pas de drag) ; sinon une pastille « Nouvelle activité » propose
|
||||
l'actualisation.
|
||||
- **Synchro par étiquettes** : les colonnes intermédiaires sont
|
||||
reflétées dans Gitea par des étiquettes `kanban:todo`,
|
||||
`kanban:in_progress`, `kanban:review` (créées au besoin, ajout /
|
||||
retrait granulaires — les étiquettes de l'utilisateur ne sont jamais
|
||||
touchées ; backlog et done = aucune étiquette). Désactivable :
|
||||
`KANBANTE_SYNC_LABELS=0`.
|
||||
- **Boards par milestone** : `GET /board/{owner}/{repo}?milestone=<titre|id>`
|
||||
filtre le board, avec bandeau d'échéance et progression
|
||||
(issues fermées du milestone / total). Sélecteur de milestones
|
||||
au-dessus du board.
|
||||
|
||||
### Interface (refonte complète)
|
||||
- Nouveau design system : en-tête vitré sticky avec navigation en
|
||||
pastilles (état actif), halos d'ambiance, cartes projets qui se
|
||||
soulèvent au survol, barres de progression, coins plus doux,
|
||||
anneau de focus visible partout, `prefers-reduced-motion` respecté.
|
||||
- Dashboard : hero avec tuiles de synthèse (projets actifs, issues
|
||||
fermées et déplacements sur 30 jours, temps de cycle moyen — depuis
|
||||
le journal), états vides guidés pour chaque onglet, raccourci `/`
|
||||
pour la recherche (aussi sur le filtre du board).
|
||||
- Board : pastille « live » en cas d'activité externe, colonnes à
|
||||
défilement indépendant, placeholders de colonnes vides, panneau de
|
||||
notes explicité, drawer animé.
|
||||
- Cohérence : même navigation sur les 5 pages, responsive retravaillé.
|
||||
|
||||
### Tests
|
||||
- 13 tests ajoutés (65 au total) : hub SSE et générateur de flux,
|
||||
webhooks (transition, idempotence, signature, JSON invalide),
|
||||
synchro d'étiquettes, boards par milestone, marqueurs de la refonte.
|
||||
|
||||
## 0.3.0 — 2026-10-11
|
||||
|
||||
« Palier 1 » : voir ce qui se passe sur les boards.
|
||||
|
||||
### Ajouts
|
||||
- **Journal d'événements** (`board_events`, append-only) : chaque
|
||||
création, déplacement, fermeture, réouverture et commentaire est
|
||||
horodaté. Il alimente la page **`/activity`** (fil tous projets),
|
||||
`GET /api/events` et `GET /api/metrics` (débit par type et temps de
|
||||
cycle moyen : entrée en « En cours » → fermeture). Les fermetures
|
||||
détectées lors de la réconciliation avec Gitea sont aussi journalisées
|
||||
(source `gitea`). Un reclassement dans la même colonne n'écrit pas
|
||||
d'événement.
|
||||
- **Panneau détail de carte** : un clic sur une carte ouvre un panneau
|
||||
latéral avec le corps de l'issue rendu en markdown (marked + DOMPurify,
|
||||
vendorés), ses commentaires, assignés, milestone et échéance ; on peut
|
||||
**commenter directement dans Gitea** depuis le panneau
|
||||
(`GET /api/issues/{owner}/{repo}/{n}`, `POST .../comments`, 404 propre
|
||||
si l'issue n'existe pas).
|
||||
- **Vieillissement des cartes** : badge « ⏳ N j » dès qu'une carte reste
|
||||
dans sa colonne au-delà de `KANBANTE_AGING_DAYS` (défaut 7, rouge au
|
||||
double). Le temps en colonne vient du journal, avec repli sur
|
||||
`board_state.updated_at` puis sur la date de création de l'issue.
|
||||
- **Limites WIP** : `KANBANTE_WIP_LIMITS="in_progress:5,review:3"` —
|
||||
l'en-tête de colonne affiche `n/limite`, passe en rouge en dépassement
|
||||
et un toast avertit au dépôt. Limites souples : jamais bloquantes.
|
||||
|
||||
### Tests
|
||||
- 16 tests ajoutés (52 au total) : journal et métriques, détail et
|
||||
commentaires, vieillissement, WIP.
|
||||
|
||||
## 0.2.0 — 2026-10-11
|
||||
|
||||
Consolidation complète à partir d'un audit du code 0.1.0 (chaque correctif
|
||||
|
||||
@@ -5,11 +5,26 @@
|
||||
## Features
|
||||
|
||||
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / inactifs / archivés — le vrai drapeau Gitea)
|
||||
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → Révision → Terminé, ordre des cartes persisté
|
||||
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → **QA** → Révision → Terminé, ordre des cartes persisté
|
||||
- 🎯 **Drag & Drop** — glisse les issues entre colonnes ; en cas d'échec, la carte est replacée
|
||||
- 🔗 **Gitea Sync bidirectionnelle** — « Terminé » ferme l'issue dans Gitea, la ressortir la rouvre ; l'état local est réconcilié à chaque chargement
|
||||
- ➕ **Création d'issues** depuis le board, filtres texte + étiquettes, cartes liées à Gitea
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
|
||||
- ⚡ **Temps réel** — un webhook Gitea invalide le cache et pousse un événement SSE : le board ouvert se met à jour tout seul (en douceur, sans interrompre une saisie ni un drag)
|
||||
- 🏷️ **Visible dans Gitea** — les colonnes intermédiaires sont reflétées par des étiquettes `kanban:*` sur les issues, et un board peut se filtrer **par milestone** (échéance + progression façon sprint)
|
||||
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
|
||||
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
|
||||
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
|
||||
- ⚡ **Capture éclair** — page `/capture` (formulaire), endpoint `POST /api/capture` protégé par un jeton dédié, et écoute ntfy : un message publié sur le topic de capture devient une issue dans le dépôt par défaut
|
||||
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
|
||||
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
|
||||
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
|
||||
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
|
||||
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
|
||||
- 📝 **Notes** — bloc-notes par projet
|
||||
- 🔐 **Authentification** Basic Auth intégrée + protection CSRF (voir Sécurité)
|
||||
- 🌙 **Dark theme** — conçu pour les longues sessions
|
||||
@@ -92,6 +107,20 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_CACHE_TTL` | `30` | Secondes de cache des listes Gitea (dépôts, issues) |
|
||||
| `KANBANTE_MAX_NOTE_CHARS` | `20000` | Taille maximale d'une note de projet |
|
||||
| `KANBANTE_SYNC_GITEA` | `1` | `0` = les déplacements ne ferment/rouvrent plus les issues Gitea |
|
||||
| `KANBANTE_WIP_LIMITS` | *(vide)* | Limites souples par colonne, ex. `in_progress:5,review:3` (affichage `n/limite`, rouge en dépassement, jamais bloquant) |
|
||||
| `KANBANTE_AGING_DAYS` | `7` | Jours dans la même colonne avant le badge « ⏳ » sur une carte (rouge au double) |
|
||||
| `KANBANTE_WEBHOOK_SECRET` | *(vide)* | Secret partagé du webhook Gitea ; si défini, la signature HMAC `X-Gitea-Signature` est exigée |
|
||||
| `KANBANTE_SYNC_LABELS` | `1` | `0` = ne plus refléter les colonnes intermédiaires en étiquettes `kanban:*` dans Gitea |
|
||||
| `KANBANTE_NTFY_URL` | *(vide)* | Base du serveur ntfy (ex. `https://ntfy.sh` ou auto-hébergé) pour le briefing |
|
||||
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
|
||||
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
|
||||
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
|
||||
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
|
||||
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
|
||||
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
|
||||
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
|
||||
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
|
||||
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
|
||||
|
||||
## Sécurité
|
||||
|
||||
@@ -133,9 +162,66 @@ kanbante/
|
||||
|---------|-------|-------------|
|
||||
| GET | `/` | Dashboard tous les projets |
|
||||
| GET | `/board/{owner}/{repo}` | Kanban board |
|
||||
| GET | `/focus` | En cours + En révision, tous projets actifs |
|
||||
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
|
||||
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
|
||||
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
|
||||
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
|
||||
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
|
||||
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
|
||||
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
|
||||
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
|
||||
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
|
||||
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
|
||||
| GET | `/api/issues/{owner}/{repo}/{n}` | Détail d'une issue + commentaires (panneau) |
|
||||
| POST | `/api/issues/{owner}/{repo}/{n}/comments` | Commenter l'issue dans Gitea |
|
||||
| GET | `/api/events` | Derniers événements du journal (`?repo=`, `?limit=`) |
|
||||
| GET | `/api/metrics` | Débit + temps de cycle moyen (`?repo=`, `?window=`) |
|
||||
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
|
||||
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
|
||||
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
|
||||
| GET | `/search?q=` | Recherche globale d'issues (tous projets, tous états) |
|
||||
| GET | `/my` | Issues ouvertes assignées à l'utilisateur du jeton |
|
||||
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
|
||||
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
|
||||
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
|
||||
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
|
||||
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
|
||||
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
|
||||
|
||||
## Brancher un client MCP
|
||||
|
||||
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
|
||||
les lectures d'abord). Configuration type (transport HTTP) :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kanbante": {
|
||||
"url": "https://<votre-hôte-kanbante>/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
|
||||
Kanbante est active, le client doit envoyer l'en-tête
|
||||
`Authorization` correspondant.
|
||||
|
||||
## Activer le temps réel (webhook Gitea)
|
||||
|
||||
Dans chaque dépôt (ou au niveau d'une organisation) : **Réglages →
|
||||
Webhooks → Ajouter un webhook → Gitea**, avec :
|
||||
|
||||
- **URL cible** : `https://<votre-hôte-kanbante>/webhooks/gitea`
|
||||
- **Type de contenu** : `application/json`
|
||||
- **Secret** : la même valeur que `KANBANTE_WEBHOOK_SECRET`
|
||||
- **Déclencheur** : « Événements d'issue » (et « Commentaire d'issue »
|
||||
si vous voulez aussi le rafraîchissement sur commentaires externes)
|
||||
|
||||
Dès lors, fermer ou rouvrir une issue dans Gitea met à jour les boards
|
||||
Kanbante ouverts, sans rechargement manuel.
|
||||
| POST | `/api/notes` | Sauvegarder des notes |
|
||||
| GET | `/api/stats` | Statistiques JSON |
|
||||
| GET | `/api/projects` | Projets JSON (actifs / inactifs / archivés) |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Daily briefing: formatting and ntfy delivery.
|
||||
|
||||
The data itself is gathered in app.main (it needs the board helpers);
|
||||
this module only turns it into a readable text and pushes it to an
|
||||
ntfy topic. Kept dependency-light so it is unit-testable alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def ntfy_configured(settings: Settings) -> bool:
|
||||
return bool(settings.ntfy_url and settings.ntfy_topic)
|
||||
|
||||
|
||||
def format_briefing_text(data: dict) -> str:
|
||||
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||||
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
||||
activity = data["activity_24h"]
|
||||
lines.append(
|
||||
f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), "
|
||||
f"{activity['created']} créée(s), {activity['moved']} déplacement(s)."
|
||||
)
|
||||
if activity.get("avg_cycle_days") is not None:
|
||||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
||||
|
||||
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||
for group in data["focus_groups"][:6]:
|
||||
lines.append(f" • {group['full_name']} : {group['count']}")
|
||||
for title in group["titles"][:3]:
|
||||
lines.append(f" – {title}")
|
||||
|
||||
if data["aging"]:
|
||||
lines.append("\n⏳ Cartes qui vieillissent :")
|
||||
for card in data["aging"][:8]:
|
||||
lines.append(
|
||||
f" • {card['repo']} #{card['number']} « {card['title']} » "
|
||||
f"— {card['days']} j en {card['column_label']}"
|
||||
)
|
||||
|
||||
if data["milestones_due"]:
|
||||
lines.append("\n📅 Milestones proches :")
|
||||
for ms in data["milestones_due"][:5]:
|
||||
lines.append(
|
||||
f" • {ms['repo']} « {ms['title']} » — échéance "
|
||||
f"{ms['due_on']} ({ms['open']} ouverte(s))"
|
||||
)
|
||||
|
||||
if data["focus_total"] == 0 and not data["aging"]:
|
||||
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_ntfy(settings: Settings, text: str) -> bool:
|
||||
"""POST the briefing to the configured ntfy topic. True on 2xx."""
|
||||
if not ntfy_configured(settings):
|
||||
return False
|
||||
url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
|
||||
headers = {
|
||||
# ntfy headers must stay ASCII; the body carries the accents.
|
||||
"Title": "Kanbante - briefing du jour",
|
||||
"Tags": "clipboard,chart_with_upwards_trend",
|
||||
}
|
||||
if settings.ntfy_token:
|
||||
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(url, content=text.encode("utf-8"), headers=headers)
|
||||
return response.status_code < 300
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
"""Quick capture (roadmap #12): turn an external message into an issue.
|
||||
|
||||
Two entry points share the same parsing:
|
||||
- POST /api/capture (token-authenticated, in app.main) — curl, a
|
||||
shortcut, any automation;
|
||||
- an ntfy *listener*: messages published to a dedicated topic become
|
||||
issues in the default capture repo. Capture from a phone is then
|
||||
just "share to the ntfy app".
|
||||
|
||||
The listener is a background task with reconnect + dedupe: the last
|
||||
processed ntfy message id is kept (by the caller, in kv_store) and
|
||||
passed back as `since`, so a restart never re-ingests old messages.
|
||||
Messages tagged "kanbante" are skipped — Kanbante never ingests its
|
||||
own posts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante.capture")
|
||||
|
||||
MAX_TITLE_CHARS = 255
|
||||
|
||||
|
||||
def parse_capture_text(text: str) -> tuple[str, str]:
|
||||
"""First non-empty line = title, the rest = body."""
|
||||
lines = (text or "").strip().splitlines()
|
||||
lines = [line.rstrip() for line in lines]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
if not lines:
|
||||
return "", ""
|
||||
title = lines[0].strip()[:MAX_TITLE_CHARS]
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
return title, body
|
||||
|
||||
|
||||
def parse_ntfy_message(msg: dict) -> tuple[str, str] | None:
|
||||
"""Extract (title, body) from an ntfy message, or None to skip it."""
|
||||
if msg.get("event") not in (None, "message"):
|
||||
return None
|
||||
if "kanbante" in (msg.get("tags") or []):
|
||||
return None
|
||||
title = str(msg.get("title") or "").strip()
|
||||
message = str(msg.get("message") or "").strip()
|
||||
if title and message:
|
||||
return title[:MAX_TITLE_CHARS], message
|
||||
if message:
|
||||
return parse_capture_text(message)
|
||||
if title:
|
||||
return title[:MAX_TITLE_CHARS], ""
|
||||
return None
|
||||
|
||||
|
||||
async def ntfy_capture_loop(create_issue, get_last_id, set_last_id) -> None:
|
||||
"""Subscribe to the capture topic forever (reconnect with backoff).
|
||||
|
||||
create_issue(title, body) is an async callback; get_last_id /
|
||||
set_last_id persist the ntfy cursor. Returns quietly when the
|
||||
listener is no longer configured.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
while True:
|
||||
settings = get_settings()
|
||||
base = settings.ntfy_url.rstrip("/")
|
||||
topic = settings.capture_ntfy_topic
|
||||
if not base or not topic:
|
||||
return
|
||||
since = get_last_id() or "latest"
|
||||
headers = {}
|
||||
if settings.ntfy_token:
|
||||
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"GET", f"{base}/{topic}/json?since={since}", headers=headers
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
"ntfy capture listening on %s/%s (since=%s)",
|
||||
base, topic, since,
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
msg_id = str(msg.get("id") or "")
|
||||
if msg_id:
|
||||
if msg_id in seen:
|
||||
continue
|
||||
seen.add(msg_id)
|
||||
if len(seen) > 500:
|
||||
seen = set(list(seen)[-250:])
|
||||
set_last_id(msg_id)
|
||||
parsed = parse_ntfy_message(msg)
|
||||
if parsed is None:
|
||||
continue
|
||||
title, body = parsed
|
||||
try:
|
||||
await create_issue(title, body)
|
||||
except Exception:
|
||||
logger.exception("capture failed for %r", title)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ntfy capture stream interrupted (%s); retrying in 10s", exc
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
+101
-1
@@ -17,6 +17,57 @@ Environment variables:
|
||||
KANBANTE_MAX_NOTE_CHARS Maximum size of a project note (default 20000).
|
||||
KANBANTE_SYNC_GITEA "1" (default) = moving a card to/from "done"
|
||||
closes / reopens the Gitea issue.
|
||||
KANBANTE_WIP_LIMITS Comma list "column:limit" (e.g.
|
||||
"in_progress:5,review:3"). Columns not
|
||||
listed are unlimited. Soft limits: the UI
|
||||
warns, moves are never blocked.
|
||||
KANBANTE_AGING_DAYS Days in the same column before a card is
|
||||
flagged as aging (default 7; "stale" at 2x).
|
||||
KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook
|
||||
(POST /webhooks/gitea). When set, the
|
||||
X-Gitea-Signature HMAC is enforced.
|
||||
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
|
||||
columns to Gitea labels named kanban:<col>.
|
||||
KANBANTE_NTFY_URL Base URL of an ntfy server (e.g.
|
||||
https://ntfy.sh or self-hosted).
|
||||
KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing.
|
||||
KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy.
|
||||
KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which
|
||||
the daily briefing is sent to ntfy.
|
||||
Empty (default) = no automatic send,
|
||||
the /briefing page stays available.
|
||||
KANBANTE_CAPTURE_REPO Default repo for quick capture
|
||||
(POST /api/capture, the /capture page
|
||||
and the ntfy capture listener), e.g.
|
||||
"Projets/kanbante". Empty = captures
|
||||
must name their repo explicitly.
|
||||
KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture
|
||||
(header X-Capture-Token or Bearer).
|
||||
Empty (default) = the endpoint is
|
||||
disabled (503). Distinct from the
|
||||
Gitea token; revocable on its own.
|
||||
KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures:
|
||||
each message published there becomes
|
||||
an issue in KANBANTE_CAPTURE_REPO.
|
||||
Uses KANBANTE_NTFY_URL/_TOKEN as the
|
||||
server. Empty (default) = no listener.
|
||||
KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is
|
||||
composed (and ntfy-notified) on
|
||||
KANBANTE_DIGEST_WEEKDAY. The digest is
|
||||
NEVER auto-delivered: it waits for a
|
||||
one-click approval on /digest.
|
||||
KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0).
|
||||
KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest
|
||||
as JSON {title, markdown, week}.
|
||||
KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the
|
||||
digest is delivered by calling a
|
||||
note-creation tool on it (client MCP).
|
||||
KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP.
|
||||
KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call
|
||||
(see /digest for the live tool list).
|
||||
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
|
||||
"{title}" and "{content}" placeholders
|
||||
are substituted (JSON-escaped).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -25,7 +76,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.2.0"
|
||||
APP_VERSION = "0.8.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -51,6 +102,16 @@ def _env_float(name: str, default: float) -> float:
|
||||
return default
|
||||
|
||||
|
||||
def _env_wip_limits(name: str) -> dict[str, int]:
|
||||
limits: dict[str, int] = {}
|
||||
for chunk in os.getenv(name, "").split(","):
|
||||
column, sep, value = chunk.partition(":")
|
||||
column = column.strip()
|
||||
if sep and column and value.strip().isdigit():
|
||||
limits[column] = int(value.strip())
|
||||
return limits
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
gitea_url: str = "https://git.dracodev.net"
|
||||
@@ -63,6 +124,24 @@ class Settings:
|
||||
cache_ttl: float = 30.0
|
||||
max_note_chars: int = 20_000
|
||||
sync_gitea: bool = True
|
||||
wip_limits: dict | None = None
|
||||
aging_days: int = 7
|
||||
webhook_secret: str = ""
|
||||
sync_labels: bool = True
|
||||
ntfy_url: str = ""
|
||||
ntfy_topic: str = ""
|
||||
ntfy_token: str = ""
|
||||
briefing_time: str = ""
|
||||
capture_repo: str = ""
|
||||
capture_token: str = ""
|
||||
capture_ntfy_topic: str = ""
|
||||
digest_time: str = ""
|
||||
digest_weekday: int = 0
|
||||
digest_webhook_url: str = ""
|
||||
obsigate_mcp_url: str = ""
|
||||
obsigate_mcp_token: str = ""
|
||||
obsigate_mcp_tool: str = ""
|
||||
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -84,4 +163,25 @@ def get_settings() -> Settings:
|
||||
cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0),
|
||||
max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000),
|
||||
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
|
||||
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
|
||||
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
|
||||
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
|
||||
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
|
||||
ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""),
|
||||
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
||||
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
||||
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
||||
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
|
||||
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
|
||||
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
|
||||
digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""),
|
||||
digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0),
|
||||
digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""),
|
||||
obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""),
|
||||
obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""),
|
||||
obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""),
|
||||
obsigate_mcp_args=os.getenv(
|
||||
"KANBANTE_OBSIGATE_MCP_ARGS",
|
||||
'{"title": "{title}", "content": "{content}"}',
|
||||
),
|
||||
)
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""Weekly digest (roadmap #13): the week in Kanbante, as a note.
|
||||
|
||||
Composes a markdown digest from the event journal (last 7 days) and
|
||||
the current focus snapshot, then — ONLY after a human approves on
|
||||
/digest (standing rule: cross-app writes need approval) — delivers
|
||||
it to ObsiGate through its MCP server (Kanbante as MCP *client*:
|
||||
initialize → tools/call) or to a generic JSON webhook.
|
||||
|
||||
The ObsiGate tool contract is configurable (URL, tool name, JSON
|
||||
argument template with {title}/{content} placeholders) because the
|
||||
exact tool names of the target server are an operator decision, not
|
||||
something to hardcode blindly.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("kanbante.digest")
|
||||
|
||||
MCP_PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
|
||||
def format_digest_markdown(
|
||||
week_label: str,
|
||||
closed_items: list[dict],
|
||||
created_count: int,
|
||||
moved_count: int,
|
||||
focus_groups: list[dict],
|
||||
) -> str:
|
||||
"""Render the weekly digest as markdown (Obsidian-friendly)."""
|
||||
lines = [f"# 🧠 Kanbante — semaine du {week_label}", ""]
|
||||
lines.append(f"## ✅ Livré ({len(closed_items)})")
|
||||
if closed_items:
|
||||
for item in closed_items:
|
||||
title = item.get("title") or ""
|
||||
lines.append(
|
||||
f"- [{item['repo']}#{item['number']}]({item.get('html_url', '')})"
|
||||
f" {title}".rstrip()
|
||||
)
|
||||
else:
|
||||
lines.append("- Rien n'a été fermé cette semaine.")
|
||||
lines.append("")
|
||||
lines.append("## 🚧 En cours")
|
||||
total_focus = sum(len(g["cards"]) for g in focus_groups)
|
||||
if total_focus:
|
||||
for group in focus_groups:
|
||||
lines.append(f"### {group['full_name']}")
|
||||
for card in group["cards"]:
|
||||
issue = card["issue"]
|
||||
lines.append(
|
||||
f"- #{issue['number']} {issue.get('title', '')}"
|
||||
f" — *{card['column']}*"
|
||||
)
|
||||
else:
|
||||
lines.append("- Aucune carte en cours, en QA ou en révision.")
|
||||
lines.append("")
|
||||
lines.append("## 📈 Activité de la semaine")
|
||||
lines.append(f"- Issues créées : {created_count}")
|
||||
lines.append(f"- Déplacements de cartes : {moved_count}")
|
||||
lines.append(f"- Issues fermées : {len(closed_items)}")
|
||||
lines.append("")
|
||||
lines.append("*Digest composé par Kanbante à partir de son journal d'événements.*")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_mcp_args(template: str, title: str, content: str) -> dict:
|
||||
"""Substitute {title}/{content} in a JSON template, safely."""
|
||||
def esc(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)[1:-1]
|
||||
|
||||
rendered = template.replace("{title}", esc(title)).replace(
|
||||
"{content}", esc(content)
|
||||
)
|
||||
try:
|
||||
args = json.loads(rendered)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"KANBANTE_OBSIGATE_MCP_ARGS is not valid JSON: {exc}")
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("KANBANTE_OBSIGATE_MCP_ARGS must be a JSON object")
|
||||
return args
|
||||
|
||||
|
||||
async def send_webhook(url: str, title: str, markdown: str, week: str) -> tuple[bool, str]:
|
||||
"""POST the digest as JSON to a generic webhook."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
url, json={"title": title, "markdown": markdown, "week": week}
|
||||
)
|
||||
if response.status_code < 300:
|
||||
return True, f"webhook HTTP {response.status_code}"
|
||||
return False, f"webhook HTTP {response.status_code}: {response.text[:200]}"
|
||||
except Exception as exc:
|
||||
return False, f"webhook error: {exc}"
|
||||
|
||||
|
||||
def _mcp_headers(token: str) -> dict:
|
||||
headers = {
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def _parse_mcp_body(response: httpx.Response) -> dict:
|
||||
"""MCP streamable HTTP answers JSON or an SSE frame — accept both."""
|
||||
text = response.text.strip()
|
||||
if not text:
|
||||
return {}
|
||||
if text.startswith("{"):
|
||||
return json.loads(text)
|
||||
payload: dict = {}
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
chunk = line[5:].strip()
|
||||
if chunk.startswith("{"):
|
||||
payload = json.loads(chunk)
|
||||
return payload
|
||||
|
||||
|
||||
async def _mcp_rpc(client: httpx.AsyncClient, url: str, token: str,
|
||||
method: str, params: dict, req_id: int) -> dict:
|
||||
response = await client.post(
|
||||
url,
|
||||
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
|
||||
headers=_mcp_headers(token),
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = _parse_mcp_body(response)
|
||||
if "error" in payload:
|
||||
err = payload["error"] or {}
|
||||
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
|
||||
return payload.get("result") or {}
|
||||
|
||||
|
||||
async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
await _mcp_rpc(
|
||||
client, url, token, "initialize",
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.8.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
await client.post(
|
||||
url,
|
||||
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||
headers=_mcp_headers(token),
|
||||
)
|
||||
|
||||
|
||||
async def mcp_list_tools(url: str, token: str) -> tuple[bool, list[str] | str]:
|
||||
"""(True, [tool names]) or (False, error message)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
await _mcp_session(client, url, token)
|
||||
result = await _mcp_rpc(client, url, token, "tools/list", {}, req_id=2)
|
||||
return True, [t.get("name", "?") for t in result.get("tools", [])]
|
||||
except Exception as exc:
|
||||
return False, f"MCP tools/list failed: {exc}"
|
||||
|
||||
|
||||
async def mcp_call_tool(
|
||||
url: str, token: str, tool: str, arguments: dict
|
||||
) -> tuple[bool, str]:
|
||||
"""Call one tool; (True, short result) or (False, error message)."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
await _mcp_session(client, url, token)
|
||||
result = await _mcp_rpc(
|
||||
client, url, token, "tools/call",
|
||||
{"name": tool, "arguments": arguments}, req_id=3,
|
||||
)
|
||||
if result.get("isError"):
|
||||
return False, f"tool {tool} reported an error: {str(result)[:300]}"
|
||||
texts = [
|
||||
block.get("text", "")
|
||||
for block in result.get("content", [])
|
||||
if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
return True, (" ".join(texts)[:300] or f"tool {tool} OK")
|
||||
except Exception as exc:
|
||||
return False, f"MCP tools/call failed: {exc}"
|
||||
+121
-2
@@ -120,6 +120,10 @@ class GiteaClient:
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
async def ping(self) -> bool:
|
||||
@@ -168,8 +172,10 @@ class GiteaClient:
|
||||
) -> list[dict]:
|
||||
"""Get issues for a repo (paginated, cached).
|
||||
|
||||
Note: Gitea's issues endpoint also returns pull requests; each
|
||||
PR carries a "pull_request" key and callers filter on it.
|
||||
Note: Gitea's issues endpoint also returns pull requests; a
|
||||
PR carries a non-null "pull_request" object, a plain issue
|
||||
carries "pull_request": null — callers filter on truthiness
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
@@ -198,6 +204,119 @@ class GiteaClient:
|
||||
issues_by_repo[full_name] = []
|
||||
return issues_by_repo
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, number: int) -> dict:
|
||||
"""One issue with body, labels, assignees, milestone."""
|
||||
return await self._get(f"/repos/{owner}/{repo}/issues/{number}")
|
||||
|
||||
async def get_issue_comments(
|
||||
self, owner: str, repo: str, number: int
|
||||
) -> list[dict]:
|
||||
"""All comments of an issue (paginated)."""
|
||||
return await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/comments"
|
||||
)
|
||||
|
||||
async def create_comment(
|
||||
self, owner: str, repo: str, number: int, body: str
|
||||
) -> dict:
|
||||
"""Post a comment on an issue and return it."""
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/comments",
|
||||
json={"body": body},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
# ── Labels (kanban:* column mirroring) ─────────────────
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
return await self._get_all_pages(f"/repos/{owner}/{repo}/labels")
|
||||
|
||||
async def create_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/labels",
|
||||
json={"name": name, "color": color.lstrip("#")},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def ensure_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
"""Return the repo label with this name, creating it if needed."""
|
||||
key = f"{owner}/{repo}"
|
||||
cache = self.__dict__.setdefault("_label_cache", {})
|
||||
if key not in cache:
|
||||
cache[key] = {l["name"]: l for l in await self.get_labels(owner, repo)}
|
||||
if name not in cache[key]:
|
||||
cache[key][name] = await self.create_label(owner, repo, name, color)
|
||||
return cache[key][name]
|
||||
|
||||
async def add_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id: int
|
||||
) -> None:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels",
|
||||
json={"labels": [label_id]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def remove_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id: int
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def delete_label(self, owner: str, repo: str, label_id: int) -> None:
|
||||
"""Delete a label definition from the repo (all issues lose it)."""
|
||||
r = await self.client.delete(f"/repos/{owner}/{repo}/labels/{label_id}")
|
||||
r.raise_for_status()
|
||||
self.forget_label_cache(owner, repo)
|
||||
|
||||
def forget_label_cache(self, owner: str, repo: str) -> None:
|
||||
cache = self.__dict__.get("_label_cache")
|
||||
if cache:
|
||||
cache.pop(f"{owner}/{repo}", None)
|
||||
|
||||
# ── Issue search (global + assigned) ───────────────────
|
||||
|
||||
async def search_issues(
|
||||
self,
|
||||
q: str = "",
|
||||
state: str = "open",
|
||||
assigned_by: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search issues across every repo visible to the token.
|
||||
|
||||
Uses Gitea's /repos/issues/search (type=issues excludes PRs).
|
||||
Results carry their repository in issue["repository"].
|
||||
"""
|
||||
params: dict[str, Any] = {"state": state, "type": "issues"}
|
||||
if q:
|
||||
params["q"] = q
|
||||
if assigned_by:
|
||||
params["assigned_by"] = assigned_by
|
||||
return await self._get_all_pages("/repos/issues/search", **params)
|
||||
|
||||
# ── Milestones ─────────────────────────────────────────
|
||||
|
||||
async def get_milestones(
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def create_issue(
|
||||
self, owner: str, repo: str, title: str, body: str = ""
|
||||
) -> dict:
|
||||
|
||||
+1548
-13
File diff suppressed because it is too large
Load Diff
+174
-2
@@ -16,7 +16,7 @@ import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
|
||||
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "qa", "review", "done"]
|
||||
|
||||
|
||||
def db_path() -> str:
|
||||
@@ -65,8 +65,27 @@ def init_db() -> None:
|
||||
UNIQUE(repo_full_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS board_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_full_name TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
from_column TEXT,
|
||||
to_column TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'ui',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||
ON board_state(repo_full_name, column_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv_store (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||
ON board_events(repo_full_name, issue_number, created_at);
|
||||
"""
|
||||
)
|
||||
_migrate(conn)
|
||||
@@ -189,7 +208,8 @@ def reconcile_board_state(
|
||||
- Rows for issues that no longer exist in Gitea are deleted.
|
||||
Returns counters of what changed.
|
||||
"""
|
||||
changed = {"closed_to_done": 0, "purged": 0}
|
||||
changed: dict = {"closed_to_done": 0, "purged": 0,
|
||||
"closed_numbers": [], "purged_numbers": []}
|
||||
known = open_numbers | closed_numbers
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -206,6 +226,7 @@ def reconcile_board_state(
|
||||
(repo_full_name, number),
|
||||
)
|
||||
changed["purged"] += 1
|
||||
changed["purged_numbers"].append(number)
|
||||
elif number in closed_numbers and row["column_name"] != "done":
|
||||
conn.execute(
|
||||
"UPDATE board_state SET column_name = 'done', "
|
||||
@@ -214,9 +235,160 @@ def reconcile_board_state(
|
||||
(repo_full_name, number),
|
||||
)
|
||||
changed["closed_to_done"] += 1
|
||||
changed["closed_numbers"].append(number)
|
||||
return changed
|
||||
|
||||
|
||||
def get_kv(key: str) -> str | None:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_kv(key: str, value: str) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO kv_store (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
||||
|
||||
|
||||
def log_event(
|
||||
repo_full_name: str,
|
||||
issue_number: int,
|
||||
event_type: str,
|
||||
from_column: str | None = None,
|
||||
to_column: str | None = None,
|
||||
source: str = "ui",
|
||||
created_at: str | None = None,
|
||||
) -> None:
|
||||
"""Append one entry to the board's event journal.
|
||||
|
||||
The journal is append-only: it is the memory board_state lacks
|
||||
(time-in-column, activity feed, throughput / cycle-time metrics).
|
||||
"""
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ValueError(f"Invalid event type: {event_type}")
|
||||
with get_db() as conn:
|
||||
if created_at:
|
||||
conn.execute(
|
||||
"INSERT INTO board_events (repo_full_name, issue_number, "
|
||||
"event_type, from_column, to_column, source, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(repo_full_name, issue_number, event_type,
|
||||
from_column, to_column, source, created_at),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO board_events (repo_full_name, issue_number, "
|
||||
"event_type, from_column, to_column, source) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(repo_full_name, issue_number, event_type,
|
||||
from_column, to_column, source),
|
||||
)
|
||||
|
||||
|
||||
def get_events(
|
||||
repo_full_name: str | None = None, limit: int = 100
|
||||
) -> list[dict]:
|
||||
"""Most recent journal entries, newest first (one repo or all)."""
|
||||
with get_db() as conn:
|
||||
if repo_full_name:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM board_events WHERE repo_full_name = ? "
|
||||
"ORDER BY id DESC LIMIT ?",
|
||||
(repo_full_name, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM board_events ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def column_entry_times(repo_full_name: str) -> dict[int, str]:
|
||||
"""When each issue entered its current column, per the journal.
|
||||
|
||||
Latest event per issue, comments excluded (a comment does not move
|
||||
a card). Issues without journal entries are absent — callers fall
|
||||
back to board_state.updated_at, then to the issue's creation date.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT issue_number, MAX(created_at) AS entered_at "
|
||||
"FROM board_events WHERE repo_full_name = ? "
|
||||
"AND event_type != 'commented' GROUP BY issue_number",
|
||||
(repo_full_name,),
|
||||
).fetchall()
|
||||
return {row["issue_number"]: row["entered_at"] for row in rows}
|
||||
|
||||
|
||||
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
|
||||
"""Throughput and cycle time computed from the journal.
|
||||
|
||||
Cycle time = time from entering in_progress (or from creation, when
|
||||
no in_progress entry exists) to the closing event, averaged over
|
||||
issues closed inside the window.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
def _parse(value: str):
|
||||
try:
|
||||
return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
with get_db() as conn:
|
||||
if repo_full_name:
|
||||
rows = conn.execute(
|
||||
"SELECT repo_full_name, issue_number, event_type, to_column, "
|
||||
"created_at FROM board_events WHERE repo_full_name = ? "
|
||||
"ORDER BY created_at, id",
|
||||
(repo_full_name,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT repo_full_name, issue_number, event_type, to_column, "
|
||||
"created_at FROM board_events ORDER BY created_at, id"
|
||||
).fetchall()
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
|
||||
counts: dict[str, int] = {}
|
||||
starts: dict[tuple, datetime] = {}
|
||||
cycles: list[float] = []
|
||||
for row in rows:
|
||||
at = _parse(row["created_at"])
|
||||
if at is None:
|
||||
continue
|
||||
if at >= cutoff:
|
||||
counts[row["event_type"]] = counts.get(row["event_type"], 0) + 1
|
||||
key = (row["repo_full_name"], row["issue_number"])
|
||||
if row["event_type"] == "created":
|
||||
starts.setdefault(key, at)
|
||||
elif row["event_type"] == "moved" and row["to_column"] == "in_progress":
|
||||
starts[key] = at # cycle clock (re)starts on entering in_progress
|
||||
elif row["event_type"] == "closed" and key in starts and at >= cutoff:
|
||||
cycles.append((at - starts[key]).total_seconds() / 86400.0)
|
||||
return {
|
||||
"window_days": window_days,
|
||||
"created": counts.get("created", 0),
|
||||
"moved": counts.get("moved", 0),
|
||||
"closed": counts.get("closed", 0),
|
||||
"reopened": counts.get("reopened", 0),
|
||||
"avg_cycle_days": round(sum(cycles) / len(cycles), 2) if cycles else None,
|
||||
"cycle_samples": len(cycles),
|
||||
}
|
||||
|
||||
|
||||
def set_notes(repo_full_name: str, note: str) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="palette-overlay" id="palette-overlay" hidden>
|
||||
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
|
||||
<div class="palette-input-row">
|
||||
<span class="palette-icon">⌨️</span>
|
||||
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Chercher un projet, une issue, une action…">
|
||||
<kbd>Échap</kbd>
|
||||
</div>
|
||||
<ul class="palette-results" id="palette-results"></ul>
|
||||
<div class="palette-hint">↑↓ naviguer · ↵ ouvrir · Échap fermer · Ctrl+K pour rouvrir</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!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>Activité — 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">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Les {{ events|length }} derniers événements du journal — déplacements, créations, fermetures et commentaires, tous projets confondus.</p>
|
||||
{% if not events %}
|
||||
<p class="focus-empty">Aucun événement pour le moment. Le journal se remplit dès que des cartes bougent.</p>
|
||||
{% endif %}
|
||||
<div class="activity-list">
|
||||
{% for e in events %}
|
||||
<div class="activity-item">
|
||||
<span class="activity-icon">{{ event_icons.get(e.event_type, '•') }}</span>
|
||||
<div class="activity-text">
|
||||
<a href="/board/{{ e.repo_full_name }}">{{ e.repo_full_name }}</a>
|
||||
<strong>#{{ e.issue_number }}</strong>
|
||||
{% if e.event_type == 'created' %}
|
||||
créée{% if e.to_column %} dans {{ column_labels.get(e.to_column, e.to_column) }}{% endif %}
|
||||
{% elif e.event_type == 'moved' %}
|
||||
déplacée{% if e.from_column %} de {{ column_labels.get(e.from_column, e.from_column) }}{% endif %} vers {{ column_labels.get(e.to_column, e.to_column) }}
|
||||
{% elif e.event_type == 'closed' %}
|
||||
fermée{% if e.source == 'gitea' %} (dans Gitea){% endif %}
|
||||
{% elif e.event_type == 'reopened' %}
|
||||
rouverte vers {{ column_labels.get(e.to_column, e.to_column) }}
|
||||
{% elif e.event_type == 'commented' %}
|
||||
commentée
|
||||
{% endif %}
|
||||
<span class="activity-date">{{ e.created_at[:16].replace('T', ' ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · journal d'événements
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+127
-33
@@ -6,34 +6,67 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>{{ repo }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script src="/static/vendor/sortable.min.js"></script>
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
<script src="/static/js/board.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>
|
||||
<a href="/">📋 Kanbante</a>
|
||||
<span class="sep">/</span>
|
||||
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
|
||||
</h1>
|
||||
<nav>
|
||||
<a href="/focus" class="btn-sm">🎯 Focus</a>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% if milestones %}
|
||||
<div class="milestone-bar">
|
||||
<span class="milestone-label">🎯 Milestone</span>
|
||||
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
|
||||
{% for m in milestones %}
|
||||
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
|
||||
class="milestone-chip{% if milestone_param == m.title or milestone_param == m.id|string %} active{% endif %}">{{ m.title }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if milestone_progress %}
|
||||
<div class="milestone-banner">
|
||||
<div class="milestone-info">
|
||||
<strong>🎯 {{ milestone_progress.title }}</strong>
|
||||
{% if milestone_progress.due_on %}
|
||||
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
|
||||
{% endif %}
|
||||
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
|
||||
</div>
|
||||
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
|
||||
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
|
||||
<div class="board-toolbar">
|
||||
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes...">
|
||||
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
|
||||
<select id="label-filter">
|
||||
<option value="">Toutes les étiquettes</option>
|
||||
{% for label in labels %}
|
||||
<option value="{{ label.name }}">{{ label.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||
<form id="create-issue-form">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
||||
@@ -43,14 +76,19 @@
|
||||
|
||||
<main class="board-container">
|
||||
<div class="kanban-board">
|
||||
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
<div class="kanban-column"
|
||||
data-column="{{ col }}">
|
||||
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
|
||||
<span class="column-title">{{ column_labels[col] }}</span>
|
||||
<span class="column-count">{{ columns[col]|length }}</span>
|
||||
{% set limit = wip_limits.get(col) %}
|
||||
<span class="column-count{% if limit and columns[col]|length > limit %} over{% endif %}"
|
||||
{% if limit %}data-limit="{{ limit }}" title="Limite WIP : {{ limit }}"{% endif %}>{% if limit %}{{ columns[col]|length }}/{{ limit }}{% else %}{{ columns[col]|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="column-cards" data-column="{{ col }}">
|
||||
{% if not columns[col] %}
|
||||
<p class="column-empty">Glissez une carte ici</p>
|
||||
{% endif %}
|
||||
{% for issue in columns[col] %}
|
||||
<div class="kanban-card"
|
||||
data-issue="{{ issue.number }}"
|
||||
@@ -78,6 +116,11 @@
|
||||
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
|
||||
{% endif %}
|
||||
<span class="card-date">{{ issue.created_at[:10] }}</span>
|
||||
{% set age = ages.get(issue.number) %}
|
||||
{% if age and age.days >= aging_days %}
|
||||
<span class="age-badge{% if age.days >= aging_days * 2 %} stale{% endif %}"
|
||||
title="Dans cette colonne depuis le {{ age.since[:10] }}">⏳ {{ age.days }} j</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -86,34 +129,85 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Notes panel -->
|
||||
<aside class="notes-panel">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<form hx-post="/api/notes" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful) showNotesSaved()">
|
||||
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="note" rows="10"
|
||||
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||
</form>
|
||||
<!-- Notes panel: collapsible + resizable + autosave -->
|
||||
<aside class="notes-panel" id="notes-panel">
|
||||
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
|
||||
<div class="notes-head">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
|
||||
title="Replier / déplier le panneau">⤢</button>
|
||||
</div>
|
||||
<div class="notes-body-wrap">
|
||||
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea. Enregistrement automatique.</p>
|
||||
<form id="notes-form">
|
||||
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||
<textarea name="note" id="notes-text" rows="10"
|
||||
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||
<div class="notes-foot">
|
||||
<span id="notes-status" class="notes-status"></span>
|
||||
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- Label manager modal -->
|
||||
<div id="label-modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<div class="detail-head">
|
||||
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
|
||||
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
|
||||
</div>
|
||||
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
|
||||
<div id="label-list" class="label-list"></div>
|
||||
<form id="label-create-form" class="label-create-form">
|
||||
<input type="text" id="new-label-name" maxlength="50" placeholder="Nom de la nouvelle étiquette" required>
|
||||
<input type="color" id="new-label-color" value="#7c5cfc" title="Couleur">
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
<div class="label-swatches">
|
||||
{% for c in ['e11d48','f59e0b','10b981','0ea5e9','7c5cfc','ec4899','64748b','84cc16'] %}
|
||||
<button type="button" class="swatch" data-color="#{{ c }}" style="background: #{{ c }}" title="#{{ c }}"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card detail drawer: issue body, comments, comment form -->
|
||||
<div id="detail-overlay" class="detail-overlay" hidden></div>
|
||||
<aside id="card-detail" class="card-detail" hidden>
|
||||
<div class="detail-head">
|
||||
<span id="detail-number" class="card-id"></span>
|
||||
<span id="detail-state" class="chip"></span>
|
||||
<button id="detail-close" class="btn-sm" type="button">✕</button>
|
||||
</div>
|
||||
<h2 id="detail-title"></h2>
|
||||
<div id="detail-meta" class="detail-meta"></div>
|
||||
<div id="detail-labels" class="card-labels"></div>
|
||||
<div class="detail-labels-manage">
|
||||
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
||||
<div id="detail-label-toggles" class="label-toggles"></div>
|
||||
</div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<div id="detail-comments"></div>
|
||||
<form id="comment-form">
|
||||
<textarea id="comment-body" rows="3" maxlength="10000"
|
||||
placeholder="Écrire un commentaire (markdown)..."></textarea>
|
||||
<button type="submit" class="btn-save">💬 Commenter dans Gitea</button>
|
||||
</form>
|
||||
<p class="detail-foot"><a id="detail-gitea-link" href="#" target="_blank">Ouvrir dans Gitea ↗</a></p>
|
||||
</aside>
|
||||
|
||||
<footer>
|
||||
<span>{{ full_name }}</span>
|
||||
{% if repo_info.updated_at %}
|
||||
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script>
|
||||
function showNotesSaved() {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = '✅ Notes sauvegardées';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
</script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Briefing — 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">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
|
||||
|
||||
<div class="stat-tiles briefing-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.focus_total }}</span><span class="stat-label">en cours / révision</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.closed }}</span><span class="stat-label">fermées · 24 h</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.moved }}</span><span class="stat-label">déplacements · 24 h</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.aging|length }}</span><span class="stat-label">cartes vieillissantes</span></div>
|
||||
</div>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📄 Contenu du briefing</h2>
|
||||
<pre class="briefing-text">{{ text }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📲 Envoi ntfy</h2>
|
||||
{% if ntfy_configured %}
|
||||
<p class="notes-hint">ntfy est configuré. {% if briefing_time %}Envoi automatique chaque jour à <strong>{{ briefing_time }}</strong> (heure du serveur).{% else %}Aucune heure d'envoi automatique (<code>KANBANTE_BRIEFING_TIME</code> non défini).{% endif %}{% if last_sent %} Dernier envoi : {{ last_sent }}.{% endif %}</p>
|
||||
<form id="briefing-send-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-save">📤 Envoyer maintenant via ntfy</button>
|
||||
</form>
|
||||
<p id="briefing-status" class="notes-hint"></p>
|
||||
{% else %}
|
||||
<p class="notes-hint">Pour recevoir ce briefing sur votre téléphone, définissez <code>KANBANTE_NTFY_URL</code> (ex. <code>https://ntfy.sh</code> ou votre ntfy auto-hébergé), <code>KANBANTE_NTFY_TOPIC</code> et, pour l'envoi automatique, <code>KANBANTE_BRIEFING_TIME=07:30</code>, puis redémarrez Kanbante.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · briefing quotidien
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('briefing-send-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('briefing-status');
|
||||
status.textContent = 'Envoi en cours...';
|
||||
const r = await fetch('/api/briefing/send', { method: 'POST', body: new FormData(e.target) });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||
});
|
||||
</script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Capture éclair — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚡ Capture éclair</h1>
|
||||
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
|
||||
</section>
|
||||
|
||||
{% if created %}
|
||||
<div class="briefing-card">
|
||||
<h2>✅ Capturé !</h2>
|
||||
<p>Issue <strong>#{{ created.number }}</strong> créée dans
|
||||
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
|
||||
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
|
||||
· <a href="/board/{{ created.repo_full_name }}">ouvrir le board</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="briefing-card">
|
||||
<h2>❌ Capture impossible</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>✍️ Saisie rapide</h2>
|
||||
<form method="post" action="/capture" class="capture-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Dépôt
|
||||
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
|
||||
</label>
|
||||
<label>Titre de l'issue
|
||||
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
|
||||
</label>
|
||||
<label>Détails (optionnel)
|
||||
<textarea name="body" rows="4" placeholder="Contexte, lien, capture d'écran à coller dans Gitea ensuite…"></textarea>
|
||||
</label>
|
||||
<label>Colonne
|
||||
<select name="column">
|
||||
<option value="backlog" selected>📥 Backlog</option>
|
||||
<option value="todo">📋 À faire</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save">⚡ Capturer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>🔌 Depuis un script ou un raccourci</h2>
|
||||
{% if api_enabled %}
|
||||
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
|
||||
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
|
||||
-H "X-Capture-Token: VOTRE_JETON" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Idée : trier les cartes par échéance\n\nDétails ici…"}'</pre>
|
||||
<p>Texte libre accepté aussi (<code>Content-Type: text/plain</code>) : la première ligne devient le titre. Le dépôt vient de <code>KANBANTE_CAPTURE_REPO</code> ou du champ <code>repo</code>.</p>
|
||||
{% else %}
|
||||
<p>L'endpoint <code>POST /api/capture</code> est <strong>inactif</strong> : définis <code>KANBANTE_CAPTURE_TOKEN</code> (un jeton dédié, distinct de celui de Gitea) puis redémarre Kanbante.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>📱 Depuis le téléphone (ntfy)</h2>
|
||||
{% if ntfy_topic %}
|
||||
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
|
||||
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
|
||||
<p>Les messages portant le tag <code>kanbante</code> sont ignorés (jamais d'auto-ingestion).</p>
|
||||
{% else %}
|
||||
<p>Inactif : définis <code>KANBANTE_CAPTURE_NTFY_TOPIC</code> (et <code>KANBANTE_CAPTURE_REPO</code>) puis redémarre Kanbante. Chaque message publié sur ce topic deviendra une issue — capture depuis l'application ntfy en deux touchers.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,15 +9,37 @@
|
||||
<script defer src="/static/vendor/alpine.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
|
||||
<nav>
|
||||
<a href="/focus" class="btn-sm">🎯 Focus</a>
|
||||
<span class="badge">{{ active_repos|length }} actifs</span>
|
||||
<span class="badge secondary">{{ archived_repos|length }} archivés</span>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-text">
|
||||
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
|
||||
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
|
||||
</div>
|
||||
<div class="stat-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main x-data="{ tab: 'active', search: '' }">
|
||||
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
|
||||
"archivé" = le vrai drapeau Gitea (repo.archived). -->
|
||||
@@ -36,7 +58,8 @@
|
||||
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
|
||||
by Jinja), never interpolated into a JS string literal. -->
|
||||
<div class="search-bar">
|
||||
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
|
||||
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
|
||||
<kbd class="kbd">/</kbd>
|
||||
</div>
|
||||
|
||||
{% macro project_card(repo, archived=False) %}
|
||||
@@ -52,11 +75,20 @@
|
||||
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
|
||||
</h3>
|
||||
<p class="desc">{{ repo.description or '—' }}</p>
|
||||
{% if full in stats %}
|
||||
{% set total = stats[full].values()|sum %}
|
||||
{% set done_n = stats[full].get('done', 0) %}
|
||||
{% if total > 0 %}
|
||||
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
|
||||
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="card-footer">
|
||||
<span>{{ repo.open_issues_count or 0 }} issues</span>
|
||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||
@@ -72,19 +104,39 @@
|
||||
|
||||
<div x-show="tab === 'active'" class="project-grid">
|
||||
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
|
||||
{% if not active_repos %}
|
||||
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'inactive'" class="project-grid">
|
||||
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
|
||||
{% if not inactive_repos %}
|
||||
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div x-show="tab === 'archived'" class="project-grid">
|
||||
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
|
||||
{% if not archived_repos %}
|
||||
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
e.preventDefault();
|
||||
document.getElementById('project-search')?.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Digest hebdo — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill active">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🧠 Digest hebdo → ObsiGate</h1>
|
||||
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
|
||||
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
|
||||
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
|
||||
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
|
||||
</section>
|
||||
|
||||
{% if banner %}
|
||||
<div class="briefing-card"><p>{{ banner }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
{% if tools is not none %}
|
||||
<div class="briefing-card">
|
||||
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
|
||||
{% if tools %}
|
||||
<ul>
|
||||
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
|
||||
</ul>
|
||||
<p>Choisis l'outil de création de note, puis définis <code>KANBANTE_OBSIGATE_MCP_TOOL</code> (et au besoin <code>KANBANTE_OBSIGATE_MCP_ARGS</code>, gabarit JSON avec <code>{title}</code> et <code>{content}</code>).</p>
|
||||
{% else %}
|
||||
<p>Le serveur répond, mais n'expose aucun outil.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>📤 Livraison</h2>
|
||||
<p>
|
||||
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
|
||||
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
|
||||
</p>
|
||||
<form method="post" action="/digest/probe" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
|
||||
</form>
|
||||
<form method="post" action="/digest/send" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="obsigate">
|
||||
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
|
||||
</form>
|
||||
<form method="post" action="/digest/send" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="target" value="webhook">
|
||||
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>👀 Aperçu du digest de la semaine</h2>
|
||||
{% if pending %}
|
||||
<pre class="capture-pre">{{ pending }}</pre>
|
||||
{% else %}
|
||||
<p>Aucun digest en attente. L'aperçu exact est composé au moment de l'envoi (boutons ci-dessus) à partir du journal des 7 derniers jours et de la vue Focus.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,8 +7,17 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="error-main">
|
||||
<div class="error-box">
|
||||
|
||||
@@ -8,9 +8,22 @@
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 🎯 Focus</h1>
|
||||
<nav>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill active">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -58,5 +71,8 @@
|
||||
<footer>
|
||||
Kanbante v{{ version }} · vue Focus
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!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>Mes issues — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
{% if not user %}
|
||||
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
||||
{% else %}
|
||||
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<span class="issue-state">🟢</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · mes issues
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!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>Recherche — 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">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<form method="get" action="/search" class="search-hero">
|
||||
<input type="text" name="q" value="{{ q }}" autofocus
|
||||
placeholder="Rechercher dans toutes les issues de tous les projets...">
|
||||
<button type="submit" class="btn-save btn-inline">Rechercher</button>
|
||||
</form>
|
||||
|
||||
{% if q %}
|
||||
<p class="focus-intro">{{ total }} résultat(s) pour « {{ q }} » — recherche Gitea, tous projets confondus.</p>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🔍 Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<span class="issue-state">{{ '✅' if issue.state != 'open' else '🟢' }}</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">🔍 Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · recherche globale
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kanbante — Wallboard</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="wall-body">
|
||||
{% if slides %}
|
||||
<div class="wall-stage" id="wall-stage">
|
||||
{% for slide in slides %}
|
||||
<section class="wall-slide{% if loop.first %} active{% endif %}">
|
||||
<header class="wall-head">
|
||||
<div>
|
||||
<div class="wall-kicker">📂 Projet</div>
|
||||
<h1 class="wall-project">{{ slide.full_name }}</h1>
|
||||
</div>
|
||||
<div class="wall-clock">
|
||||
<div class="wall-time" data-clock>--:--</div>
|
||||
<div class="wall-date" data-date></div>
|
||||
{% if not single and slides|length > 1 %}
|
||||
<div class="wall-rotation">{{ loop.index }} / {{ slides|length }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wall-counts">
|
||||
{% for col in columns %}
|
||||
<div class="wall-count" style="--col: {{ column_colors[col] }}">
|
||||
<span class="wall-count-n">{{ slide.counts[col] }}</span>
|
||||
<span class="wall-count-l">{{ column_labels[col] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if slide.cards %}
|
||||
<ul class="wall-cards">
|
||||
{% for card in slide.cards %}
|
||||
<li class="wall-card">
|
||||
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
|
||||
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
|
||||
{% if card.age_days is not none and card.age_days >= 1 %}
|
||||
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
|
||||
{% endif %}
|
||||
|
||||
<footer class="wall-foot">
|
||||
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
|
||||
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
|
||||
<span class="wall-live">● EN DIRECT</span>
|
||||
</footer>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="wall-empty">
|
||||
<h1>🖥️ Wallboard</h1>
|
||||
<p>Aucun projet actif à afficher{% if single %} pour ce dépôt{% endif %}.</p>
|
||||
<p class="wall-empty-hint">Astuce : <code>/wall?repo=Proprietaire/depot</code> fige un seul projet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<script src="/static/js/wall.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.2.0"
|
||||
version = "0.8.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -24,3 +24,7 @@ dev = [
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
# Always put the repo root on sys.path: the plain `pytest` binary (CI)
|
||||
# does not add the CWD, unlike `python -m pytest` — without this,
|
||||
# `from tests.conftest import ...` fails at collection time in CI.
|
||||
pythonpath = ["."]
|
||||
|
||||
+823
-187
File diff suppressed because it is too large
Load Diff
+381
-1
@@ -28,10 +28,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
new Sortable(col, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
draggable: '.kanban-card',
|
||||
ghostClass: 'dragging',
|
||||
onStart: function () { window.__kanbanteDragging = true; },
|
||||
dragClass: 'dragging',
|
||||
onEnd: function (evt) {
|
||||
window.__kanbanteDragging = false;
|
||||
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
|
||||
refreshEmptyHints();
|
||||
const card = evt.item;
|
||||
const formData = new FormData();
|
||||
formData.append('repo_full_name', card.dataset.repo);
|
||||
@@ -50,6 +54,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
})
|
||||
.then(data => {
|
||||
updateCounts();
|
||||
const targetCol = evt.to.closest('.kanban-column');
|
||||
const badge = targetCol?.querySelector('.column-count');
|
||||
if (badge?.classList.contains('over')) {
|
||||
showToast('⚠️ Limite WIP dépassée dans ' + evt.to.dataset.column.replace('_', ' ') + ' (' + badge.textContent + ')', true);
|
||||
}
|
||||
let msg = '✅ Déplacé vers ' + evt.to.dataset.column.replace('_', ' ');
|
||||
if (data.gitea_sync === 'closed') msg += ' · issue fermée dans Gitea';
|
||||
if (data.gitea_sync === 'reopened') msg += ' · issue rouverte dans Gitea';
|
||||
@@ -62,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
.catch(err => {
|
||||
console.error('Move failed:', err);
|
||||
revert(evt);
|
||||
refreshEmptyHints();
|
||||
showToast('❌ Échec du déplacement — carte replacée', true);
|
||||
});
|
||||
}
|
||||
@@ -108,14 +118,384 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The "Glissez une carte ici" hint is rendered by the server only
|
||||
// for columns empty at page load. After a drag, the DOM is the
|
||||
// truth: remove the hint from columns that hold cards, restore it
|
||||
// on columns that became empty. (Bug reported 2026-10-11: the
|
||||
// hint stayed forever after dropping a card into an empty column.)
|
||||
function refreshEmptyHints() {
|
||||
document.querySelectorAll('.column-cards').forEach(list => {
|
||||
const hint = list.querySelector('.column-empty');
|
||||
const hasCards = !!list.querySelector('.kanban-card');
|
||||
if (hasCards && hint) hint.remove();
|
||||
if (!hasCards && !hint) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'column-empty';
|
||||
p.textContent = 'Glissez une carte ici';
|
||||
list.appendChild(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
document.querySelectorAll('.kanban-column').forEach(col => {
|
||||
const count = col.querySelectorAll('.kanban-card').length;
|
||||
const badge = col.querySelector('.column-count');
|
||||
if (badge) badge.textContent = count;
|
||||
if (!badge) return;
|
||||
const limit = parseInt(badge.dataset.limit || '0', 10);
|
||||
if (limit > 0) {
|
||||
badge.textContent = count + '/' + limit;
|
||||
badge.classList.toggle('over', count > limit);
|
||||
} else {
|
||||
badge.textContent = count;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard: "/" jumps to the filter box.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
|
||||
e.preventDefault();
|
||||
document.getElementById('card-filter')?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Repo context (repoFullName is declared at the top) ──
|
||||
const [repoOwner, repoName] = (repoFullName || '').split('/', 2);
|
||||
|
||||
// ── Notes panel: collapsible, resizable, autosave ──────
|
||||
const notesPanel = document.getElementById('notes-panel');
|
||||
if (notesPanel) {
|
||||
const widthKey = 'kb.notes.w.' + repoFullName;
|
||||
const collapsedKey = 'kb.notes.c.' + repoFullName;
|
||||
const savedWidth = parseInt(localStorage.getItem(widthKey) || '0', 10);
|
||||
if (savedWidth >= 200 && savedWidth <= 560) notesPanel.style.width = savedWidth + 'px';
|
||||
const toggleBtn = document.getElementById('notes-toggle');
|
||||
function applyCollapsed(collapsed) {
|
||||
notesPanel.classList.toggle('collapsed', collapsed);
|
||||
toggleBtn.textContent = collapsed ? '⤡' : '⤢';
|
||||
toggleBtn.title = collapsed ? 'Déplier le panneau' : 'Replier le panneau';
|
||||
}
|
||||
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
|
||||
toggleBtn?.addEventListener('click', () => {
|
||||
const collapsed = !notesPanel.classList.contains('collapsed');
|
||||
applyCollapsed(collapsed);
|
||||
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
|
||||
});
|
||||
|
||||
const handle = document.getElementById('notes-resize');
|
||||
handle?.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = notesPanel.getBoundingClientRect().width;
|
||||
function onMove(ev) {
|
||||
const w = Math.min(560, Math.max(200, startW + (startX - ev.clientX)));
|
||||
notesPanel.style.width = w + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
localStorage.setItem(widthKey, String(Math.round(notesPanel.getBoundingClientRect().width)));
|
||||
}
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
});
|
||||
|
||||
const notesText = document.getElementById('notes-text');
|
||||
const notesStatus = document.getElementById('notes-status');
|
||||
let notesTimer = null;
|
||||
async function saveNotes() {
|
||||
const fd = new FormData();
|
||||
fd.append('repo_full_name', repoFullName);
|
||||
fd.append('note', notesText.value);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
notesStatus.textContent = '💾 Enregistrement...';
|
||||
try {
|
||||
const r = await fetch('/api/notes', { method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
notesStatus.textContent = '✓ Enregistré à ' + new Date().toLocaleTimeString('fr-CA');
|
||||
} catch (err) {
|
||||
notesStatus.textContent = '❌ Échec : ' + err.message;
|
||||
}
|
||||
}
|
||||
notesText?.addEventListener('input', () => {
|
||||
notesStatus.textContent = '✎ Modifié...';
|
||||
clearTimeout(notesTimer);
|
||||
notesTimer = setTimeout(saveNotes, 1200);
|
||||
});
|
||||
document.getElementById('notes-form')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(notesTimer);
|
||||
saveNotes();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Label manager ──────────────────────────────────────
|
||||
let repoLabelsCache = null;
|
||||
async function fetchRepoLabels() {
|
||||
if (repoLabelsCache) return repoLabelsCache;
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
repoLabelsCache = data.labels;
|
||||
return repoLabelsCache;
|
||||
}
|
||||
|
||||
function labelChipEl(label) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'label-tag';
|
||||
const color = /^[0-9a-fA-F]{6}$/.test(label.color || '') ? label.color : '888888';
|
||||
span.style.background = '#' + color + '30';
|
||||
span.style.color = '#' + color;
|
||||
span.textContent = label.name;
|
||||
return span;
|
||||
}
|
||||
|
||||
const labelModal = document.getElementById('label-modal');
|
||||
async function renderLabelList() {
|
||||
const box = document.getElementById('label-list');
|
||||
box.innerHTML = '';
|
||||
try {
|
||||
const labels = await fetchRepoLabels();
|
||||
if (!labels.length) box.innerHTML = '<p class="detail-empty">Aucune étiquette dans ce dépôt.</p>';
|
||||
labels.forEach(label => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'label-row';
|
||||
row.appendChild(labelChipEl(label));
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn-sm label-del';
|
||||
del.textContent = '🗑️ Supprimer';
|
||||
del.addEventListener('click', async () => {
|
||||
if (!confirm('Supprimer l\'étiquette « ' + label.name + ' » de tout le dépôt ?')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels/${label.id}`,
|
||||
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
repoLabelsCache = repoLabelsCache.filter(l => l.id !== label.id);
|
||||
row.remove();
|
||||
showToast('✅ Étiquette supprimée');
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
row.appendChild(del);
|
||||
box.appendChild(row);
|
||||
});
|
||||
} catch (err) {
|
||||
box.innerHTML = '<p class="detail-empty">Erreur : ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
document.getElementById('labels-btn')?.addEventListener('click', () => {
|
||||
labelModal.hidden = false;
|
||||
renderLabelList();
|
||||
});
|
||||
document.getElementById('label-modal-close')?.addEventListener('click', () => { labelModal.hidden = true; });
|
||||
labelModal?.addEventListener('click', (e) => { if (e.target === labelModal) labelModal.hidden = true; });
|
||||
document.querySelectorAll('.swatch').forEach(sw => sw.addEventListener('click', () => {
|
||||
document.getElementById('new-label-color').value = sw.dataset.color;
|
||||
}));
|
||||
document.getElementById('label-create-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('new-label-name').value.trim();
|
||||
const color = document.getElementById('new-label-color').value.replace('#', '');
|
||||
if (!name) return;
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('color', color);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`, { method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
if (repoLabelsCache) repoLabelsCache.push(data.label);
|
||||
document.getElementById('new-label-name').value = '';
|
||||
renderLabelList();
|
||||
showToast('✅ Étiquette « ' + data.label.name + ' » créée');
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
|
||||
// ── Card detail drawer ─────────────────────────────────
|
||||
// A click opens the panel; a drag must not. Track pointer travel.
|
||||
let downX = 0, downY = 0;
|
||||
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
|
||||
document.querySelectorAll('.kanban-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
|
||||
if (e.target.closest('a')) return;
|
||||
openDetail(card.dataset.repo, card.dataset.issue);
|
||||
});
|
||||
});
|
||||
|
||||
const drawer = document.getElementById('card-detail');
|
||||
const overlay = document.getElementById('detail-overlay');
|
||||
let currentIssue = null; // {repo, number}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (window.marked && window.DOMPurify) {
|
||||
return DOMPurify.sanitize(marked.parse(text || ''));
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
drawer.hidden = true;
|
||||
overlay.hidden = true;
|
||||
currentIssue = null;
|
||||
}
|
||||
document.getElementById('detail-close')?.addEventListener('click', closeDetail);
|
||||
overlay?.addEventListener('click', closeDetail);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
|
||||
|
||||
async function openDetail(repoFull, number) {
|
||||
const [owner, repo] = repoFull.split('/', 2);
|
||||
currentIssue = { repo: repoFull, number };
|
||||
drawer.hidden = false;
|
||||
overlay.hidden = false;
|
||||
document.getElementById('detail-number').textContent = '#' + number;
|
||||
document.getElementById('detail-title').textContent = 'Chargement...';
|
||||
document.getElementById('detail-body').innerHTML = '';
|
||||
document.getElementById('detail-comments').innerHTML = '';
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
fillDetail(data.issue, data.comments);
|
||||
} catch (err) {
|
||||
document.getElementById('detail-title').textContent = 'Erreur de chargement';
|
||||
showToast('❌ Détail indisponible : ' + err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function fillDetail(issue, comments) {
|
||||
document.getElementById('detail-title').textContent = issue.title;
|
||||
const stateChip = document.getElementById('detail-state');
|
||||
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
|
||||
const meta = [];
|
||||
if (issue.milestone) meta.push('🎯 ' + issue.milestone);
|
||||
if (issue.due_date) meta.push('📅 échéance ' + String(issue.due_date).slice(0, 10));
|
||||
meta.push('créée le ' + String(issue.created_at).slice(0, 10));
|
||||
for (const a of issue.assignees || []) {
|
||||
meta.push('👤 ' + a.login);
|
||||
}
|
||||
document.getElementById('detail-meta').textContent = meta.join(' · ');
|
||||
const labelsBox = document.getElementById('detail-labels');
|
||||
labelsBox.innerHTML = '';
|
||||
(issue.labels || []).forEach(l => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'label-tag';
|
||||
const color = /^[0-9a-fA-F]{6}$/.test(l.color || '') ? l.color : '888888';
|
||||
span.style.background = '#' + color + '30';
|
||||
span.style.color = '#' + color;
|
||||
span.textContent = l.name;
|
||||
labelsBox.appendChild(span);
|
||||
});
|
||||
renderLabelToggles(issue);
|
||||
document.getElementById('detail-body').innerHTML =
|
||||
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
|
||||
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
|
||||
const box = document.getElementById('detail-comments');
|
||||
box.innerHTML = '';
|
||||
(comments || []).forEach(c => box.appendChild(commentNode(c)));
|
||||
if (!comments || comments.length === 0) {
|
||||
box.innerHTML = '<p class="detail-empty">Aucun commentaire.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function renderLabelToggles(issue) {
|
||||
const box = document.getElementById('detail-label-toggles');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
let all = [];
|
||||
try { all = await fetchRepoLabels(); } catch { return; }
|
||||
const activeIds = new Set((issue.labels || []).map(l => l.id));
|
||||
all.forEach(label => {
|
||||
const chip = labelChipEl(label);
|
||||
chip.classList.add('label-toggle');
|
||||
if (activeIds.has(label.id)) chip.classList.add('on');
|
||||
chip.title = activeIds.has(label.id) ? 'Retirer de l\'issue' : 'Ajouter à l\'issue';
|
||||
chip.addEventListener('click', async () => {
|
||||
const action = activeIds.has(label.id) ? 'remove' : 'add';
|
||||
const fd = new FormData();
|
||||
fd.append('label_id', label.id);
|
||||
fd.append('action', action);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const [owner, repo] = currentIssue.repo.split('/', 2);
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/labels`,
|
||||
{ method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
issue.labels = data.labels;
|
||||
// Refresh the chips in the drawer and on the board card.
|
||||
const labelsBox = document.getElementById('detail-labels');
|
||||
labelsBox.innerHTML = '';
|
||||
data.labels.forEach(l => labelsBox.appendChild(labelChipEl(l)));
|
||||
updateCardLabels(currentIssue.number, data.labels);
|
||||
renderLabelToggles(issue);
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
box.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCardLabels(number, labels) {
|
||||
const card = document.querySelector(`.kanban-card[data-issue="${number}"]`);
|
||||
if (!card) return;
|
||||
card.dataset.labels = labels.map(l => (l.name || '').toLowerCase()).join(',');
|
||||
let box = card.querySelector('.card-labels');
|
||||
if (!box && labels.length) {
|
||||
box = document.createElement('div');
|
||||
box.className = 'card-labels';
|
||||
card.querySelector('.card-title')?.after(box);
|
||||
}
|
||||
if (box) {
|
||||
box.innerHTML = '';
|
||||
labels.forEach(l => box.appendChild(labelChipEl(l)));
|
||||
box.style.display = labels.length ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function commentNode(c) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
const head = document.createElement('div');
|
||||
head.className = 'comment-head';
|
||||
head.textContent = '👤 ' + (c.user?.login || '?') + ' · ' + String(c.created_at).slice(0, 16).replace('T', ' ');
|
||||
const bodyEl = document.createElement('div');
|
||||
bodyEl.className = 'comment-body';
|
||||
bodyEl.innerHTML = renderMarkdown(c.body || '');
|
||||
div.appendChild(head);
|
||||
div.appendChild(bodyEl);
|
||||
return div;
|
||||
}
|
||||
|
||||
document.getElementById('comment-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentIssue) return;
|
||||
const textarea = document.getElementById('comment-body');
|
||||
const body = textarea.value.trim();
|
||||
if (!body) return;
|
||||
const [owner, repo] = currentIssue.repo.split('/', 2);
|
||||
const formData = new FormData();
|
||||
formData.append('body', body);
|
||||
formData.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/comments`, { method: 'POST', body: formData });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
const box = document.getElementById('detail-comments');
|
||||
if (box.querySelector('.detail-empty')) box.innerHTML = '';
|
||||
box.appendChild(commentNode(data.comment));
|
||||
textarea.value = '';
|
||||
showToast('✅ Commentaire publié dans Gitea');
|
||||
} catch (err) {
|
||||
showToast('❌ Échec du commentaire : ' + err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
function showToast(msg, isError = false) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast' + (isError ? ' error' : '');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Kanbante — live updates via Server-Sent Events.
|
||||
// When Gitea reports activity (webhook -> /api/stream), reload the
|
||||
// page softly if it is safe (visible tab, no drawer open, no text
|
||||
// being typed, no drag in progress); otherwise show a pill the
|
||||
// user can click when ready.
|
||||
(function () {
|
||||
if (!window.EventSource) return;
|
||||
|
||||
var pill = null;
|
||||
var timer = null;
|
||||
|
||||
function showPill() {
|
||||
if (pill) return;
|
||||
pill = document.createElement('button');
|
||||
pill.id = 'live-pill';
|
||||
pill.type = 'button';
|
||||
pill.textContent = '🔄 Nouvelle activité Gitea — actualiser';
|
||||
pill.addEventListener('click', function () { window.location.reload(); });
|
||||
document.body.appendChild(pill);
|
||||
}
|
||||
|
||||
function safeToReload() {
|
||||
if (document.visibilityState !== 'visible') return false;
|
||||
if (window.__kanbanteDragging) return false;
|
||||
var drawer = document.getElementById('card-detail');
|
||||
if (drawer && !drawer.hidden) return false;
|
||||
var active = document.activeElement;
|
||||
if (active && (active.tagName === 'TEXTAREA' ||
|
||||
(active.tagName === 'INPUT' && active.type === 'text'))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleReload() {
|
||||
if (timer) return;
|
||||
timer = setTimeout(function () {
|
||||
timer = null;
|
||||
if (safeToReload()) window.location.reload();
|
||||
else showPill();
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
var source = new EventSource('/api/stream');
|
||||
source.addEventListener('gitea', scheduleReload);
|
||||
})();
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Kanbante — Command palette (Ctrl+K), roadmap #10.
|
||||
*
|
||||
* One overlay for the whole app: static actions, every project, and
|
||||
* global issue search (via /api/palette). Fully keyboard driven.
|
||||
*/
|
||||
(function () {
|
||||
var overlay = document.getElementById('palette-overlay');
|
||||
if (!overlay) return;
|
||||
var input = document.getElementById('palette-input');
|
||||
var list = document.getElementById('palette-results');
|
||||
|
||||
var ACTIONS = [
|
||||
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
|
||||
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
|
||||
{ icon: '👤', label: 'Mes issues', href: '/my' },
|
||||
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
|
||||
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
|
||||
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
|
||||
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
|
||||
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
|
||||
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
|
||||
];
|
||||
|
||||
var projects = null; // cached after first fetch
|
||||
var issues = []; // latest search results for the query
|
||||
var items = []; // rendered items
|
||||
var activeIndex = 0;
|
||||
var debounce = null;
|
||||
var lastQuery = '';
|
||||
|
||||
function open() {
|
||||
overlay.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
input.value = '';
|
||||
issues = [];
|
||||
lastQuery = '';
|
||||
render('');
|
||||
input.focus();
|
||||
if (projects === null) {
|
||||
fetch('/api/palette')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
projects = data.projects || [];
|
||||
render(input.value.trim());
|
||||
})
|
||||
.catch(function () { projects = []; });
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
overlay.hidden = true;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function buildItems(query) {
|
||||
var q = query.toLowerCase();
|
||||
var out = [];
|
||||
ACTIONS.forEach(function (a) {
|
||||
if (!q || a.label.toLowerCase().includes(q)) {
|
||||
out.push({ icon: a.icon, label: a.label, sub: 'Action', href: a.href });
|
||||
}
|
||||
});
|
||||
(projects || []).forEach(function (p) {
|
||||
if (out.length >= 14) return;
|
||||
if (!q || p.full_name.toLowerCase().includes(q)) {
|
||||
out.push({
|
||||
icon: '📂', label: p.full_name, sub: 'Projet · ouvrir le board',
|
||||
href: '/board/' + p.full_name,
|
||||
});
|
||||
}
|
||||
});
|
||||
issues.forEach(function (i) {
|
||||
out.push({
|
||||
icon: '🐛',
|
||||
label: '#' + i.number + ' · ' + i.title,
|
||||
sub: i.repo + ' · ' + i.column_label + (i.state === 'closed' ? ' · fermée' : ''),
|
||||
href: '/board/' + i.repo,
|
||||
});
|
||||
});
|
||||
return out.slice(0, 14);
|
||||
}
|
||||
|
||||
function render(query) {
|
||||
items = buildItems(query);
|
||||
activeIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
|
||||
list.innerHTML = '';
|
||||
if (!items.length) {
|
||||
var empty = document.createElement('li');
|
||||
empty.className = 'palette-empty';
|
||||
empty.textContent = 'Aucun résultat — essaie un nom de projet ou un mot du titre d\'une issue.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
items.forEach(function (item, idx) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'palette-item' + (idx === activeIndex ? ' active' : '');
|
||||
var icon = document.createElement('span');
|
||||
icon.className = 'palette-item-icon';
|
||||
icon.textContent = item.icon;
|
||||
var main = document.createElement('span');
|
||||
main.className = 'palette-item-main';
|
||||
main.textContent = item.label;
|
||||
var sub = document.createElement('span');
|
||||
sub.className = 'palette-item-sub';
|
||||
sub.textContent = item.sub;
|
||||
li.appendChild(icon);
|
||||
li.appendChild(main);
|
||||
li.appendChild(sub);
|
||||
li.addEventListener('click', function () { go(item); });
|
||||
li.addEventListener('mousemove', function () {
|
||||
if (activeIndex !== idx) { activeIndex = idx; render(input.value.trim()); }
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function go(item) {
|
||||
if (item) window.location.href = item.href;
|
||||
}
|
||||
|
||||
function searchIssues(query) {
|
||||
if (query.length < 2) { issues = []; render(query); return; }
|
||||
fetch('/api/palette?q=' + encodeURIComponent(query))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (input.value.trim() !== query) return; // stale response
|
||||
issues = data.issues || [];
|
||||
if (projects === null) projects = data.projects || [];
|
||||
render(query);
|
||||
})
|
||||
.catch(function () { /* keep previous results */ });
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var query = input.value.trim();
|
||||
activeIndex = 0;
|
||||
render(query);
|
||||
if (query !== lastQuery) {
|
||||
lastQuery = query;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(function () { searchIssues(query); }, 180);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, items.length - 1);
|
||||
render(input.value.trim());
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, 0);
|
||||
render(input.value.trim());
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
go(items[activeIndex]);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', function (e) {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
var combo = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k';
|
||||
if (combo) {
|
||||
e.preventDefault();
|
||||
if (overlay.hidden) open(); else close();
|
||||
} else if (e.key === 'Escape' && !overlay.hidden) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.palette-trigger').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { open(); });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Kanbante — Wallboard: slide rotation, clock, live refresh.
|
||||
*
|
||||
* The wall is a display, not a workspace: no interaction to protect,
|
||||
* so a realtime event simply reloads the page (plus a slow safety
|
||||
* reload in case the stream drops silently).
|
||||
*/
|
||||
(function () {
|
||||
var slides = Array.from(document.querySelectorAll('.wall-slide'));
|
||||
var ROTATE_MS = 30000;
|
||||
var current = 0;
|
||||
|
||||
if (slides.length > 1) {
|
||||
setInterval(function () {
|
||||
slides[current].classList.remove('active');
|
||||
current = (current + 1) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
}, ROTATE_MS);
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
var now = new Date();
|
||||
var time = now.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });
|
||||
var date = now.toLocaleDateString('fr-CA', {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
});
|
||||
document.querySelectorAll('[data-clock]').forEach(function (el) { el.textContent = time; });
|
||||
document.querySelectorAll('[data-date]').forEach(function (el) { el.textContent = date; });
|
||||
}
|
||||
tickClock();
|
||||
setInterval(tickClock, 15000);
|
||||
|
||||
// Live: any board activity refreshes the wall.
|
||||
try {
|
||||
var source = new EventSource('/api/stream');
|
||||
source.onmessage = function () { location.reload(); };
|
||||
} catch (err) {
|
||||
console.warn('Wallboard live stream unavailable:', err);
|
||||
}
|
||||
// Safety net.
|
||||
setTimeout(function () { location.reload(); }, 5 * 60 * 1000);
|
||||
})();
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
"""Kanbante test suite.
|
||||
|
||||
This package marker matters: with it, pytest puts the repository root on
|
||||
sys.path, so `from tests.conftest import ...` resolves identically
|
||||
whether the suite is launched via the `pytest` binary (CI) or via
|
||||
`python -m pytest` (which additionally adds the CWD). The root is also
|
||||
pinned explicitly through `pythonpath` in pyproject.toml.
|
||||
"""
|
||||
+131
-4
@@ -45,14 +45,20 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
||||
issue = {
|
||||
"number": number,
|
||||
"title": title,
|
||||
"body": f"Corps de l'issue {number} en **markdown**.",
|
||||
"state": state,
|
||||
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"assignee": None,
|
||||
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"assignee": {"login": "bruno", "avatar_url": ""} if number == 1 else None,
|
||||
"assignees": [{"login": "bruno", "avatar_url": ""}] if number == 1 else [],
|
||||
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
|
||||
"due_date": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"updated_at": "2026-09-02T09:00:00Z",
|
||||
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||
# Faithful to real Gitea 1.27 payloads: the key is ALWAYS
|
||||
# present — null for a plain issue, an object for a PR.
|
||||
"pull_request": {"url": "http://gitea.test/pr"} if pr else None,
|
||||
}
|
||||
if pr:
|
||||
issue["pull_request"] = {"url": "http://gitea.test/pr"}
|
||||
return issue
|
||||
|
||||
|
||||
@@ -74,8 +80,25 @@ class FakeGitea:
|
||||
_issue(5, "Tâche focus"),
|
||||
],
|
||||
}
|
||||
self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}}
|
||||
self.label_ops: list[tuple] = []
|
||||
self._label_seq = 100
|
||||
self.cache_invalidations = 0
|
||||
self.state_changes: list[tuple] = []
|
||||
self.created: list[tuple] = []
|
||||
self.comments: dict[tuple, list[dict]] = {
|
||||
("Projets/demo", 1): [
|
||||
{
|
||||
"id": 1,
|
||||
"body": "Premier commentaire",
|
||||
"created_at": "2026-09-03T10:00:00Z",
|
||||
"updated_at": "2026-09-03T10:00:00Z",
|
||||
"html_url": "http://gitea.test/comment/1",
|
||||
"user": {"login": "bruno", "avatar_url": ""},
|
||||
}
|
||||
],
|
||||
}
|
||||
self.posted_comments: list[tuple] = []
|
||||
self.ping_result = True
|
||||
self.raise_on_search = False
|
||||
self.fail_state_change = False
|
||||
@@ -87,6 +110,37 @@ class FakeGitea:
|
||||
async def ping(self) -> bool:
|
||||
return self.ping_result
|
||||
|
||||
async def get_user(self):
|
||||
return {"id": 1, "login": "bruno"}
|
||||
|
||||
async def search_issues(self, q="", state="open", assigned_by=None):
|
||||
results = []
|
||||
for full_name, issues in self.issues.items():
|
||||
for issue in issues:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
if state != "all" and issue["state"] != state:
|
||||
continue
|
||||
if q and q.lower() not in issue["title"].lower():
|
||||
continue
|
||||
if assigned_by:
|
||||
logins = [a["login"] for a in issue.get("assignees") or []]
|
||||
if issue.get("assignee"):
|
||||
logins.append(issue["assignee"]["login"])
|
||||
if assigned_by not in logins:
|
||||
continue
|
||||
results.append({**issue, "repository": {"full_name": full_name}})
|
||||
return results
|
||||
|
||||
async def delete_label(self, owner, repo, label_id):
|
||||
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
for name in [n for n, l in labels.items() if l["id"] == label_id]:
|
||||
del labels[name]
|
||||
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
||||
|
||||
def forget_label_cache(self, owner, repo):
|
||||
pass
|
||||
|
||||
async def search_repos(self):
|
||||
if self.raise_on_search:
|
||||
raise httpx.HTTPError("simulated Gitea outage")
|
||||
@@ -105,6 +159,78 @@ class FakeGitea:
|
||||
items = [i for i in items if i["state"] == state]
|
||||
return items
|
||||
|
||||
async def get_issue(self, owner, repo, number):
|
||||
import httpx as _httpx
|
||||
|
||||
for issue in self.issues.get(f"{owner}/{repo}", []):
|
||||
if issue["number"] == number:
|
||||
return issue
|
||||
request = _httpx.Request("GET", "http://gitea.test")
|
||||
raise _httpx.HTTPStatusError(
|
||||
"404", request=request, response=_httpx.Response(404, request=request)
|
||||
)
|
||||
|
||||
async def get_issue_comments(self, owner, repo, number):
|
||||
return list(self.comments.get((f"{owner}/{repo}", number), []))
|
||||
|
||||
async def create_comment(self, owner, repo, number, body):
|
||||
comment = {
|
||||
"id": 500 + len(self.posted_comments),
|
||||
"body": body,
|
||||
"created_at": "2026-10-11T10:00:00Z",
|
||||
"updated_at": "2026-10-11T10:00:00Z",
|
||||
"html_url": "http://gitea.test/comment/new",
|
||||
"user": {"login": "bruno", "avatar_url": ""},
|
||||
}
|
||||
self.comments.setdefault((f"{owner}/{repo}", number), []).append(comment)
|
||||
self.posted_comments.append((owner, repo, number, body))
|
||||
return comment
|
||||
|
||||
def invalidate_cache(self):
|
||||
self.cache_invalidations += 1
|
||||
|
||||
async def get_labels(self, owner, repo):
|
||||
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
|
||||
|
||||
async def create_label(self, owner, repo, name, color):
|
||||
self._label_seq += 1
|
||||
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
|
||||
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
|
||||
self.label_ops.append(("create", f"{owner}/{repo}", name))
|
||||
return label
|
||||
|
||||
async def ensure_label(self, owner, repo, name, color):
|
||||
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
if name in existing:
|
||||
return existing[name]
|
||||
return await self.create_label(owner, repo, name, color)
|
||||
|
||||
async def add_issue_label(self, owner, repo, number, label_id):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
label = next(
|
||||
l for l in self.repo_labels[f"{owner}/{repo}"].values()
|
||||
if l["id"] == label_id
|
||||
)
|
||||
if label not in issue["labels"]:
|
||||
issue["labels"].append(label)
|
||||
self.label_ops.append(("add", number, label["name"]))
|
||||
|
||||
async def remove_issue_label(self, owner, repo, number, label_id):
|
||||
issue = await self.get_issue(owner, repo, number)
|
||||
removed = [l for l in issue["labels"] if l["id"] == label_id]
|
||||
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
|
||||
for label in removed:
|
||||
self.label_ops.append(("remove", number, label["name"]))
|
||||
|
||||
async def get_milestones(self, owner, repo, state="open"):
|
||||
return [
|
||||
{
|
||||
"id": 1, "title": "Sprint 1",
|
||||
"due_on": "2026-10-20T00:00:00Z",
|
||||
"open_issues": 1, "closed_issues": 1,
|
||||
}
|
||||
]
|
||||
|
||||
async def create_issue(self, owner, repo, title, body=""):
|
||||
self._next_number += 1
|
||||
issue = _issue(self._next_number, title)
|
||||
@@ -129,6 +255,7 @@ def clean_db():
|
||||
with models.get_db() as conn:
|
||||
conn.execute("DELETE FROM board_state")
|
||||
conn.execute("DELETE FROM project_notes")
|
||||
conn.execute("DELETE FROM board_events")
|
||||
yield
|
||||
|
||||
|
||||
|
||||
+3
-1
@@ -5,10 +5,12 @@ from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def test_health_is_public_liveness(client):
|
||||
from app.config import APP_VERSION
|
||||
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "ok"
|
||||
assert r.json()["version"] == "0.2.0"
|
||||
assert r.json()["version"] == APP_VERSION
|
||||
|
||||
|
||||
def test_readiness_reflects_gitea(client, fake_gitea):
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Capture éclair (feuille de route #12) : API à jeton, page web, ntfy."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import models
|
||||
from app.capture import parse_capture_text, parse_ntfy_message
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
TOKEN = "jeton-de-capture-test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_env(monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_REPO", "Projets/demo")
|
||||
|
||||
|
||||
# ── Parsing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_capture_text_first_line_is_title():
|
||||
title, body = parse_capture_text("Idée géniale\n\nDétails\nsur deux lignes")
|
||||
assert title == "Idée géniale"
|
||||
assert body == "Détails\nsur deux lignes"
|
||||
|
||||
|
||||
def test_parse_capture_text_empty():
|
||||
assert parse_capture_text(" \n ") == ("", "")
|
||||
|
||||
|
||||
def test_parse_ntfy_message_variants():
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "title": "Titre", "message": "Corps"}
|
||||
) == ("Titre", "Corps")
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "message": "Ligne un\n\nLigne deux"}
|
||||
) == ("Ligne un", "Ligne deux")
|
||||
assert parse_ntfy_message({"event": "open"}) is None
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "message": "auto", "tags": ["kanbante"]}
|
||||
) is None # never ingest our own posts
|
||||
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_capture_api_disabled_without_token(client, fake_gitea):
|
||||
r = client.post("/api/capture", json={"text": "Idée"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_capture_api_rejects_wrong_token(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"text": "Idée"},
|
||||
headers={"X-Capture-Token": "mauvais"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert fake_gitea.created == []
|
||||
|
||||
|
||||
def test_capture_api_creates_issue_in_default_repo(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"text": "Idée éclair\n\nLe détail qui compte"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
number = data["issue"]["number"]
|
||||
assert fake_gitea.created == [("Projets", "demo", "Idée éclair")]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert number in [c["issue_number"] for c in state["backlog"]]
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo")
|
||||
if e["issue_number"] == number
|
||||
]
|
||||
assert any(
|
||||
e["event_type"] == "created" and e["source"] == "capture" for e in events
|
||||
)
|
||||
|
||||
|
||||
def test_capture_api_text_plain_and_bearer(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
content="Titre en texte brut\nCorps",
|
||||
headers={
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert fake_gitea.created == [("Projets", "demo", "Titre en texte brut")]
|
||||
|
||||
|
||||
def test_capture_api_explicit_repo_and_column(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"title": "Direct en todo", "repo": "Projets/demo", "column": "todo"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
number = r.json()["issue"]["number"]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert number in [c["issue_number"] for c in state["todo"]]
|
||||
|
||||
|
||||
def test_capture_api_validates_input(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture", json={"text": " "},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"title": "X", "repo": "pas-de-slash"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_capture_api_no_repo_anywhere(client, fake_gitea, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
|
||||
monkeypatch.delenv("KANBANTE_CAPTURE_REPO", raising=False)
|
||||
r = client.post(
|
||||
"/api/capture", json={"title": "Orpheline"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Page web ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_capture_page_renders(client, capture_env):
|
||||
r = client.get("/capture")
|
||||
assert r.status_code == 200
|
||||
assert "Capture éclair" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_capture_web_form_creates_issue(client, fake_gitea, capture_env):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/capture",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "title": "Via le formulaire",
|
||||
"body": "Détails", "column": "backlog", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "Capturé" in r.text
|
||||
assert fake_gitea.created == [("Projets", "demo", "Via le formulaire")]
|
||||
@@ -76,7 +76,8 @@ def test_reconcile_closed_to_done_and_purge_deleted():
|
||||
models.move_issue("a/b", 2, "todo")
|
||||
models.move_issue("a/b", 3, "review")
|
||||
changed = models.reconcile_board_state("a/b", open_numbers={1}, closed_numbers={2})
|
||||
assert changed == {"closed_to_done": 1, "purged": 1}
|
||||
assert changed["closed_to_done"] == 1 and changed["purged"] == 1
|
||||
assert changed["closed_numbers"] == [2] and changed["purged_numbers"] == [3]
|
||||
state = models.get_board_state("a/b")
|
||||
assert [c["issue_number"] for c in state["in_progress"]] == [1]
|
||||
assert [c["issue_number"] for c in state["done"]] == [2]
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Palier 1 (0.3.0): event journal, card detail panel, aging + WIP limits."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def _days_ago(days: int) -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=days)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
|
||||
# ── Journal ────────────────────────────────────────────────
|
||||
|
||||
def test_log_event_and_get_events():
|
||||
models.log_event("a/b", 1, "created", to_column="backlog")
|
||||
models.log_event("a/b", 1, "moved", from_column="backlog", to_column="todo")
|
||||
events = models.get_events("a/b")
|
||||
assert [e["event_type"] for e in events] == ["moved", "created"]
|
||||
assert events[0]["from_column"] == "backlog"
|
||||
|
||||
|
||||
def test_log_event_rejects_unknown_type():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
models.log_event("a/b", 1, "exploded")
|
||||
|
||||
|
||||
def test_move_via_api_writes_journal(client):
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "done", "from_column": "backlog",
|
||||
"to_order": "1", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
types = [e["event_type"] for e in models.get_events("Projets/demo")]
|
||||
assert types == ["closed", "moved"] # newest first
|
||||
|
||||
|
||||
def test_same_column_reorder_does_not_log_move(client):
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 5,
|
||||
"to_column": "backlog", "from_column": "backlog",
|
||||
"to_order": "5,1", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert models.get_events("Projets/demo") == []
|
||||
|
||||
|
||||
def test_create_issue_logs_created_event(client):
|
||||
token = csrf_from(client)
|
||||
client.post(
|
||||
"/api/issues",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "title": "Nouvelle",
|
||||
"column": "todo", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
events = models.get_events("Projets/demo")
|
||||
assert events and events[0]["event_type"] == "created"
|
||||
assert events[0]["to_column"] == "todo"
|
||||
|
||||
|
||||
def test_events_api_and_activity_page(client):
|
||||
models.log_event("Projets/demo", 1, "moved",
|
||||
from_column="backlog", to_column="review")
|
||||
r = client.get("/api/events?repo=Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["events"][0]["event_type"] == "moved"
|
||||
r = client.get("/activity")
|
||||
assert r.status_code == 200
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_metrics_counts_and_cycle_time():
|
||||
models.log_event("a/b", 1, "created", to_column="backlog",
|
||||
created_at=_days_ago(10))
|
||||
models.log_event("a/b", 1, "moved", from_column="backlog",
|
||||
to_column="in_progress", created_at=_days_ago(9))
|
||||
models.log_event("a/b", 1, "closed", to_column="done",
|
||||
created_at=_days_ago(4))
|
||||
metrics = models.get_metrics("a/b", window_days=30)
|
||||
assert metrics["created"] == 1
|
||||
assert metrics["closed"] == 1
|
||||
assert metrics["avg_cycle_days"] == 5.0 # in_progress (J-9) -> closed (J-4)
|
||||
r = models.get_metrics("a/b", window_days=30)
|
||||
assert r["cycle_samples"] == 1
|
||||
|
||||
|
||||
def test_column_entry_times_ignores_comments():
|
||||
models.log_event("a/b", 1, "moved", to_column="todo",
|
||||
created_at=_days_ago(3))
|
||||
models.log_event("a/b", 1, "commented", created_at=_days_ago(0))
|
||||
times = models.column_entry_times("a/b")
|
||||
assert times[1] == _days_ago(3)
|
||||
|
||||
|
||||
# ── Panneau détail ─────────────────────────────────────────
|
||||
|
||||
def test_issue_detail_returns_body_and_comments(client):
|
||||
r = client.get("/api/issues/Projets/demo/1")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["issue"]["title"] == "Tâche un"
|
||||
assert "markdown" in data["issue"]["body"]
|
||||
assert data["comments"][0]["body"] == "Premier commentaire"
|
||||
assert data["comments"][0]["user"]["login"] == "bruno"
|
||||
|
||||
|
||||
def test_issue_detail_404(client):
|
||||
r = client.get("/api/issues/Projets/demo/999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_post_comment_goes_to_gitea_and_journals(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/comments",
|
||||
data={"body": "Bien vu !", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert fake_gitea.posted_comments == [("Projets", "demo", 1, "Bien vu !")]
|
||||
assert models.get_events("Projets/demo")[0]["event_type"] == "commented"
|
||||
|
||||
|
||||
def test_post_comment_requires_csrf_and_body(client):
|
||||
r = client.post("/api/issues/Projets/demo/1/comments", data={"body": "x"})
|
||||
assert r.status_code == 403
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/1/comments",
|
||||
data={"body": " ", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_board_contains_detail_drawer(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert 'id="card-detail"' in r.text
|
||||
assert "marked.min.js" in r.text and "purify.min.js" in r.text
|
||||
|
||||
|
||||
# ── Vieillissement + WIP ───────────────────────────────────
|
||||
|
||||
def test_aging_badge_after_threshold(client):
|
||||
models.move_issue("Projets/demo", 1, "todo")
|
||||
models.log_event("Projets/demo", 1, "moved", to_column="todo",
|
||||
created_at=_days_ago(12))
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert "age-badge" in r.text
|
||||
assert "12 j" in r.text
|
||||
|
||||
|
||||
def test_no_aging_badge_for_fresh_card(client):
|
||||
# Every open card entered its column recently: no badge anywhere.
|
||||
# (A card with no journal entry falls back to its Gitea creation
|
||||
# date — the fake issues are old, so both cards get fresh entries.)
|
||||
models.move_issue("Projets/demo", 1, "todo")
|
||||
models.log_event("Projets/demo", 1, "moved", to_column="todo",
|
||||
created_at=_days_ago(1))
|
||||
models.log_event("Projets/demo", 5, "created", to_column="backlog",
|
||||
created_at=_days_ago(1))
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert "age-badge" not in r.text
|
||||
|
||||
|
||||
def test_wip_limit_marks_column_over(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_WIP_LIMITS", "in_progress:1")
|
||||
models.move_issue("Projets/demo", 1, "in_progress")
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert 'data-limit="1"' in r.text
|
||||
assert "2/1" in r.text
|
||||
assert "over" in r.text
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Palier 2 (0.4.0): webhooks + SSE live, label sync, milestone boards."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from app.main import hub
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
def _move(client, number, to_column, from_column):
|
||||
token = csrf_from(client)
|
||||
return client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": number,
|
||||
"to_column": to_column, "from_column": from_column,
|
||||
"csrf_token": token,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _webhook(client, payload, event="issues", secret=None, raw=None):
|
||||
body = raw if raw is not None else json.dumps(payload).encode()
|
||||
headers = {"X-Gitea-Event": event, "Content-Type": "application/json"}
|
||||
if secret is not None:
|
||||
headers["X-Gitea-Signature"] = hmac.new(
|
||||
secret.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return client.post("/webhooks/gitea", content=body, headers=headers)
|
||||
|
||||
|
||||
# ── Hub SSE ────────────────────────────────────────────────
|
||||
|
||||
def test_hub_broadcast_reaches_subscribers():
|
||||
async def scenario():
|
||||
queue = hub.subscribe()
|
||||
try:
|
||||
await hub.broadcast({"repo": "a/b", "action": "closed"})
|
||||
return await asyncio.wait_for(queue.get(), timeout=1)
|
||||
finally:
|
||||
hub.unsubscribe(queue)
|
||||
|
||||
payload = asyncio.run(scenario())
|
||||
assert payload["action"] == "closed"
|
||||
assert hub.subscriber_count == 0
|
||||
|
||||
|
||||
def test_sse_event_stream_frames_and_cleanup():
|
||||
# The generator is unit-tested directly: HTTP test clients cannot
|
||||
# reliably tear down an infinite SSE stream (verified empirically).
|
||||
from app.main import sse_event_stream
|
||||
|
||||
class FakeRequest:
|
||||
async def is_disconnected(self):
|
||||
return False
|
||||
|
||||
async def scenario():
|
||||
gen = sse_event_stream(FakeRequest())
|
||||
first = await gen.__anext__()
|
||||
await hub.broadcast({"repo": "a/b", "action": "closed"})
|
||||
second = await asyncio.wait_for(gen.__anext__(), timeout=5)
|
||||
await gen.aclose()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(scenario())
|
||||
assert first == "retry: 5000\n\n"
|
||||
assert second.startswith("event: gitea\ndata: ")
|
||||
assert '"action": "closed"' in second
|
||||
assert hub.subscriber_count == 0 # finally unsubscribed
|
||||
|
||||
|
||||
# ── Webhooks ───────────────────────────────────────────────
|
||||
|
||||
def test_webhook_closed_applies_transition_and_journals(client, fake_gitea):
|
||||
models.move_issue("Projets/demo", 1, "todo")
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}
|
||||
r = _webhook(client, payload)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["handled"] == "closed"
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["done"]] == [1]
|
||||
events = models.get_events("Projets/demo")
|
||||
assert events[0]["event_type"] == "closed"
|
||||
assert events[0]["source"] == "webhook"
|
||||
assert fake_gitea.cache_invalidations >= 1
|
||||
|
||||
# The echo of our own API moves arrives in the same state: no-op,
|
||||
# no duplicate journal entry.
|
||||
r = _webhook(client, payload)
|
||||
assert r.json()["handled"] == "noop"
|
||||
assert len(models.get_events("Projets/demo")) == 1
|
||||
|
||||
|
||||
def test_webhook_reopened_moves_back_to_backlog(client):
|
||||
models.move_issue("Projets/demo", 1, "done")
|
||||
r = _webhook(client, {
|
||||
"action": "reopened",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
})
|
||||
assert r.json()["handled"] == "reopened"
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert [c["issue_number"] for c in state["backlog"]] == [1]
|
||||
|
||||
|
||||
def test_webhook_signature_enforced_when_secret_set(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_WEBHOOK_SECRET", "s3cret")
|
||||
payload = {
|
||||
"action": "closed",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}
|
||||
assert _webhook(client, payload).status_code == 401
|
||||
assert _webhook(client, payload, secret="wrong").status_code == 401
|
||||
assert _webhook(client, payload, secret="s3cret").status_code == 200
|
||||
|
||||
|
||||
def test_webhook_invalid_json_is_400(client):
|
||||
r = _webhook(client, None, raw=b"{not json")
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_webhook_ignores_unrelated_events(client):
|
||||
r = _webhook(client, {
|
||||
"action": "created",
|
||||
"repository": {"full_name": "Projets/demo"},
|
||||
"issue": {"number": 1},
|
||||
}, event="issue_comment")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["handled"] == "ignored"
|
||||
|
||||
|
||||
# ── Synchro par étiquettes ─────────────────────────────────
|
||||
|
||||
def test_label_sync_follows_column_moves(client, fake_gitea):
|
||||
r = _move(client, 1, "in_progress", "backlog")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("create", "Projets/demo", "kanban:in_progress") in fake_gitea.label_ops
|
||||
assert ("add", 1, "kanban:in_progress") in fake_gitea.label_ops
|
||||
|
||||
r = _move(client, 1, "review", "in_progress")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("remove", 1, "kanban:in_progress") in fake_gitea.label_ops
|
||||
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
|
||||
|
||||
r = _move(client, 1, "done", "review")
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("remove", 1, "kanban:review") in fake_gitea.label_ops
|
||||
issue = fake_gitea.issues["Projets/demo"][0]
|
||||
assert not any(l["name"].startswith("kanban:") for l in issue["labels"])
|
||||
assert any(l["name"] == "bug" for l in issue["labels"]) # user's label kept
|
||||
|
||||
|
||||
def test_label_sync_can_be_disabled(client, fake_gitea, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_SYNC_LABELS", "0")
|
||||
r = _move(client, 1, "in_progress", "backlog")
|
||||
assert r.json()["label_sync"] == "skipped"
|
||||
assert fake_gitea.label_ops == []
|
||||
|
||||
|
||||
# ── Boards par milestone ───────────────────────────────────
|
||||
|
||||
def test_board_filters_by_milestone_with_progress(client):
|
||||
models.move_issue("Projets/demo", 3, "done")
|
||||
r = client.get("/board/Projets/demo?milestone=Sprint%201")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche un" in r.text # open, in Sprint 1
|
||||
assert "Tâche focus" not in r.text # open, no milestone
|
||||
assert "milestone-banner" in r.text
|
||||
assert "1/2 issues terminées" in r.text
|
||||
assert "50%" in r.text
|
||||
assert "2026-10-20" in r.text # due_on from the milestone
|
||||
|
||||
|
||||
def test_board_lists_milestone_chips(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert "milestone-chip" in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
assert "milestone=Sprint" in r.text
|
||||
|
||||
|
||||
def test_board_filter_by_milestone_id(client):
|
||||
r = client.get("/board/Projets/demo?milestone=1")
|
||||
assert "Tâche un" in r.text
|
||||
assert "Tâche focus" not in r.text
|
||||
|
||||
|
||||
# ── Refonte UI : marqueurs structurels ─────────────────────
|
||||
|
||||
def test_dashboard_uses_new_design_system(client):
|
||||
r = client.get("/")
|
||||
assert "app-header" in r.text
|
||||
assert "nav-pill" in r.text
|
||||
assert "stat-tile" in r.text
|
||||
assert "live.js" in r.text
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Palier 3 (0.5.0): search + my issues, briefing/ntfy, MCP, labels UI API."""
|
||||
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Point 7 : recherche globale + mes issues ───────────────
|
||||
|
||||
def test_search_page_without_query(client):
|
||||
r = client.get("/search")
|
||||
assert r.status_code == 200
|
||||
assert "search-hero" in r.text
|
||||
|
||||
|
||||
def test_search_finds_issues_across_repos(client):
|
||||
r = client.get("/search?q=focus")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" in r.text
|
||||
assert "Tâche un" not in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_search_includes_closed_with_done_chip(client):
|
||||
r = client.get("/search?q=ancienne")
|
||||
assert "Ancienne tâche" in r.text
|
||||
assert "Terminé" in r.text # column chip for closed issues
|
||||
|
||||
|
||||
def test_my_issues_lists_only_assigned_open(client):
|
||||
r = client.get("/my")
|
||||
assert r.status_code == 200
|
||||
assert "bruno" in r.text
|
||||
assert "Tâche un" in r.text # assigned to bruno, open
|
||||
assert "Tâche focus" not in r.text # not assigned
|
||||
|
||||
|
||||
# ── Point 8 : briefing ─────────────────────────────────────
|
||||
|
||||
def test_kv_store_roundtrip():
|
||||
models.set_kv("k1", "v1")
|
||||
assert models.get_kv("k1") == "v1"
|
||||
models.set_kv("k1", "v2")
|
||||
assert models.get_kv("k1") == "v2"
|
||||
assert models.get_kv("absent") is None
|
||||
|
||||
|
||||
def test_briefing_page_and_api(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/briefing")
|
||||
assert r.status_code == 200
|
||||
assert "briefing-text" in r.text
|
||||
r = client.get("/api/briefing")
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["briefing"]["focus_total"] >= 1
|
||||
assert "Kanbante" in data["text"]
|
||||
|
||||
|
||||
def test_briefing_send_requires_ntfy_config(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/briefing/send", data={"csrf_token": token})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_briefing_send_posts_to_ntfy(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_NTFY_URL", "https://ntfy.example")
|
||||
monkeypatch.setenv("KANBANTE_NTFY_TOPIC", "kanbante-bruno")
|
||||
captured = {}
|
||||
|
||||
async def fake_send(settings, text):
|
||||
captured["topic"] = settings.ntfy_topic
|
||||
captured["text"] = text
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.main.send_ntfy", fake_send)
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/briefing/send", data={"csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["sent"] is True
|
||||
assert captured["topic"] == "kanbante-bruno"
|
||||
assert "briefing" in captured["text"]
|
||||
assert models.get_kv("briefing_last_sent") is not None
|
||||
|
||||
|
||||
def test_briefing_format_is_plain_text():
|
||||
from app.briefing import format_briefing_text
|
||||
|
||||
text = format_briefing_text({
|
||||
"date": "11/10/2026",
|
||||
"activity_24h": {"closed": 2, "created": 1, "moved": 5, "avg_cycle_days": 3.5},
|
||||
"focus_total": 1,
|
||||
"focus_groups": [{"full_name": "a/b", "count": 1, "titles": ["X"]}],
|
||||
"aging": [{"repo": "a/b", "number": 4, "title": "Vieille",
|
||||
"days": 12, "column_label": "À faire"}],
|
||||
"milestones_due": [{"repo": "a/b", "title": "S1",
|
||||
"due_on": "2026-10-15", "open": 3}],
|
||||
})
|
||||
assert "2 fermée(s)" in text
|
||||
assert "Vieille" in text and "12 j" in text
|
||||
assert "S1" in text
|
||||
|
||||
|
||||
# ── Point 9 : MCP ──────────────────────────────────────────
|
||||
|
||||
def _rpc(client, method, params=None, rpc_id=1):
|
||||
payload = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return client.post("/mcp", json=payload)
|
||||
|
||||
|
||||
def test_mcp_initialize_and_tools_list(client):
|
||||
r = _rpc(client, "initialize", {"protocolVersion": "2024-11-05"})
|
||||
result = r.json()["result"]
|
||||
assert result["serverInfo"]["name"] == "kanbante"
|
||||
assert result["protocolVersion"] == "2024-11-05"
|
||||
r = _rpc(client, "tools/list")
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert names == [
|
||||
"list_projects", "get_board", "get_issue", "get_focus",
|
||||
"search_issues", "get_activity", "get_metrics",
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_call_get_board_and_search(client):
|
||||
r = _rpc(client, "tools/call",
|
||||
{"name": "get_board", "arguments": {"owner": "Projets", "repo": "demo"}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is False
|
||||
board = json.loads(result["content"][0]["text"])
|
||||
assert any(c["title"] == "Tâche un" for c in board["backlog"])
|
||||
|
||||
r = _rpc(client, "tools/call",
|
||||
{"name": "search_issues", "arguments": {"query": "focus"}})
|
||||
found = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert [f["title"] for f in found] == ["Tâche focus"]
|
||||
|
||||
|
||||
def test_mcp_call_metrics_and_activity(client):
|
||||
models.log_event("Projets/demo", 1, "moved", to_column="todo")
|
||||
r = _rpc(client, "tools/call", {"name": "get_activity", "arguments": {}})
|
||||
events = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert events[0]["event_type"] == "moved"
|
||||
r = _rpc(client, "tools/call", {"name": "get_metrics", "arguments": {}})
|
||||
metrics = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert metrics["moved"] >= 1
|
||||
|
||||
|
||||
def test_mcp_unknown_method_and_tool(client):
|
||||
r = _rpc(client, "resources/list")
|
||||
assert r.json()["error"]["code"] == -32601
|
||||
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is True # read-only server: no such tool
|
||||
|
||||
|
||||
def test_mcp_notification_gets_202(client):
|
||||
r = client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
||||
def test_mcp_info_endpoint(client):
|
||||
r = client.get("/mcp")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["read_only"] is True
|
||||
|
||||
|
||||
# ── Board : gestion des étiquettes ────────────────────────
|
||||
|
||||
def test_labels_api_create_list_delete(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "urgent", "color": "e11d48", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
label = r.json()["label"]
|
||||
assert label["name"] == "urgent"
|
||||
|
||||
r = client.get("/api/repos/Projets/demo/labels")
|
||||
assert [l["name"] for l in r.json()["labels"]] == ["urgent"]
|
||||
|
||||
r = client.request(
|
||||
"DELETE", f"/api/repos/Projets/demo/labels/{label['id']}",
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/api/repos/Projets/demo/labels")
|
||||
assert r.json()["labels"] == []
|
||||
|
||||
|
||||
def test_labels_api_validation_and_csrf(client):
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "x", "color": "ffffff"},
|
||||
)
|
||||
assert r.status_code == 403 # no CSRF token
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "x", "color": "pashex", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_issue_label_toggle(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "revue", "color": "0ea5e9", "csrf_token": token},
|
||||
)
|
||||
label_id = r.json()["label"]["id"]
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "add", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert [l["name"] for l in r.json()["labels"]] == ["revue"]
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "remove", "csrf_token": token},
|
||||
)
|
||||
assert r.json()["labels"] == []
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "explode", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_board_has_label_manager_and_new_notes_panel(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert 'id="labels-btn"' in r.text
|
||||
assert 'id="label-modal"' in r.text
|
||||
assert 'id="notes-panel"' in r.text
|
||||
assert 'id="notes-resize"' in r.text
|
||||
assert 'id="notes-toggle"' in r.text
|
||||
assert 'id="notes-status"' in r.text
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Palier 4 (0.6.0): QA column, label reconciliation, wallboard."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import models
|
||||
from tests.conftest import _issue, csrf_from
|
||||
|
||||
|
||||
def _column_block(html: str, column: str) -> str:
|
||||
blocks = html.split('<div class="kanban-column"')
|
||||
for block in blocks[1:]:
|
||||
if f'data-column="{column}"' in block.split(">", 1)[0]:
|
||||
return block
|
||||
raise AssertionError(f"column {column} not found")
|
||||
|
||||
|
||||
def _labelled(number: int, title: str, label_names: list[str]) -> dict:
|
||||
issue = _issue(number, title)
|
||||
issue["labels"] = [
|
||||
{"id": 900 + i, "name": name, "color": "8b5cf6"}
|
||||
for i, name in enumerate(label_names)
|
||||
]
|
||||
return issue
|
||||
|
||||
|
||||
# ── QA column ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_board_has_six_columns_including_qa(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "🧪 QA" in r.text
|
||||
positions = [
|
||||
r.text.index(f'data-column="{col}"')
|
||||
for col in ("backlog", "todo", "in_progress", "qa", "review", "done")
|
||||
]
|
||||
assert positions == sorted(positions) # QA between in_progress and review
|
||||
|
||||
|
||||
def test_move_to_qa_syncs_kanban_qa_label(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "qa", "from_column": "backlog",
|
||||
"to_order": "1", "from_order": "5", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("add", 1, "kanban:qa") in fake_gitea.label_ops
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert 1 in [c["issue_number"] for c in state["qa"]]
|
||||
|
||||
|
||||
def test_qa_cards_appear_in_focus(client, fake_gitea):
|
||||
models.move_issue("Projets/demo", 5, "qa")
|
||||
r = client.get("/focus")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" in r.text
|
||||
assert "🧪 QA" in r.text
|
||||
|
||||
|
||||
# ── Label reconciliation (adoption) ───────────────────────────────
|
||||
|
||||
|
||||
def test_board_adopts_diverging_kanban_label(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Déplacée via Gitea", ["kanban:review"])
|
||||
)
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Déplacée via Gitea" in _column_block(r.text, "review")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert 9 in [c["issue_number"] for c in state["review"]]
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo") if e["issue_number"] == 9
|
||||
]
|
||||
assert any(
|
||||
e["event_type"] == "moved" and e["to_column"] == "review"
|
||||
and e["source"] == "gitea"
|
||||
for e in events
|
||||
)
|
||||
|
||||
|
||||
def test_board_ignores_conflicting_kanban_labels(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Étiquettes en conflit", ["kanban:todo", "kanban:review"])
|
||||
)
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Étiquettes en conflit" in _column_block(r.text, "backlog")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
elsewhere = [
|
||||
c["issue_number"]
|
||||
for col, cards in state.items() if col != "backlog"
|
||||
for c in cards
|
||||
]
|
||||
assert 9 not in elsewhere
|
||||
|
||||
|
||||
def test_adoption_is_idempotent(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Stable", ["kanban:qa"])
|
||||
)
|
||||
client.get("/board/Projets/demo")
|
||||
client.get("/board/Projets/demo")
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo")
|
||||
if e["issue_number"] == 9 and e["event_type"] == "moved"
|
||||
]
|
||||
assert len(events) == 1 # adopted once, then label and state agree
|
||||
|
||||
|
||||
# ── Wallboard ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wall_renders_active_projects(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/wall")
|
||||
assert r.status_code == 200
|
||||
assert "Projets/demo" in r.text
|
||||
assert "Tâche focus" in r.text # the in-progress card is on the wall
|
||||
assert 'class="wall-slide active"' in r.text
|
||||
assert "🧪 QA" in r.text # the six column counters include QA
|
||||
|
||||
|
||||
def test_wall_single_repo_mode(client):
|
||||
r = client.get("/wall", params={"repo": "Projets/demo"})
|
||||
assert r.status_code == 200
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_wall_unknown_repo_shows_empty_state(client):
|
||||
r = client.get("/wall", params={"repo": "Inconnu/xyz"})
|
||||
assert r.status_code == 200
|
||||
assert "Aucun projet actif" in r.text
|
||||
|
||||
|
||||
def test_wall_is_behind_auth(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as secured:
|
||||
assert secured.get("/wall").status_code == 401
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Palier 5 (0.8.0): palette de commandes (#10) + digest hebdo (#13)."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app import digest as digest_service
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Palette (#10) ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_palette_endpoint_projects_and_issues(client, fake_gitea):
|
||||
r = client.get("/api/palette", params={"q": "tâche"})
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert any(p["full_name"] == "Projets/demo" for p in data["projects"])
|
||||
titles = [i["title"] for i in data["issues"]]
|
||||
assert "Tâche un" in titles
|
||||
found = next(i for i in data["issues"] if i["title"] == "Tâche un")
|
||||
assert found["repo"] == "Projets/demo"
|
||||
assert found["column"] == "backlog"
|
||||
|
||||
|
||||
def test_palette_short_query_skips_issue_search(client):
|
||||
r = client.get("/api/palette", params={"q": "x"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["issues"] == []
|
||||
assert r.json()["projects"] # projects are always there
|
||||
|
||||
|
||||
def test_palette_markup_on_pages(client):
|
||||
for path in ("/", "/focus", "/capture"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert 'id="palette-overlay"' in r.text, path
|
||||
assert "/static/js/palette.js" in r.text, path
|
||||
assert "palette-trigger" in r.text, path
|
||||
|
||||
|
||||
# ── Digest : composition (#13) ────────────────────────────────────
|
||||
|
||||
|
||||
def test_digest_markdown_format():
|
||||
md = digest_service.format_digest_markdown(
|
||||
"2026-10-05 au 2026-10-11",
|
||||
[{"repo": "Projets/demo", "number": 3, "title": "Ancienne tâche",
|
||||
"html_url": "http://gitea.test/x"}],
|
||||
created_count=2,
|
||||
moved_count=5,
|
||||
focus_groups=[{
|
||||
"full_name": "Projets/demo",
|
||||
"cards": [{"issue": {"number": 5, "title": "Tâche focus"},
|
||||
"column": "🚧 En cours"}],
|
||||
}],
|
||||
)
|
||||
assert "# 🧠 Kanbante — semaine du 2026-10-05" in md
|
||||
assert "## ✅ Livré (1)" in md
|
||||
assert "Projets/demo#3" in md
|
||||
assert "Tâche focus" in md
|
||||
assert "Déplacements de cartes : 5" in md
|
||||
|
||||
|
||||
def test_render_mcp_args_escapes_content():
|
||||
args = digest_service.render_mcp_args(
|
||||
'{"title": "{title}", "content": "{content}", "folder": "Kanbante"}',
|
||||
'Titre "cité"',
|
||||
"Ligne 1\nLigne 2 avec \\ barre",
|
||||
)
|
||||
assert args["title"] == 'Titre "cité"'
|
||||
assert args["content"] == "Ligne 1\nLigne 2 avec \\ barre"
|
||||
assert args["folder"] == "Kanbante"
|
||||
with pytest.raises(ValueError):
|
||||
digest_service.render_mcp_args("{pas json", "t", "c")
|
||||
|
||||
|
||||
def test_digest_page_renders(client):
|
||||
r = client.get("/digest")
|
||||
assert r.status_code == 200
|
||||
assert "Digest hebdo" in r.text
|
||||
assert "approbation" in r.text
|
||||
|
||||
|
||||
# ── Digest : livraison ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _mcp_handler_factory(calls: list):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
payload = _json.loads(request.content.decode() or "{}")
|
||||
calls.append(payload.get("method"))
|
||||
method = payload.get("method")
|
||||
if method == "initialize":
|
||||
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1,
|
||||
"result": {"protocolVersion": "2024-11-05", "capabilities": {}}})
|
||||
if method == "notifications/initialized":
|
||||
return httpx.Response(202, json={})
|
||||
if method == "tools/list":
|
||||
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 2,
|
||||
"result": {"tools": [{"name": "create_note"}, {"name": "search"}]}})
|
||||
if method == "tools/call":
|
||||
calls.append(payload["params"])
|
||||
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 3,
|
||||
"result": {"content": [{"type": "text", "text": "note créée"}]}})
|
||||
return httpx.Response(200, json={})
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_transport(monkeypatch):
|
||||
calls: list = []
|
||||
transport = httpx.MockTransport(_mcp_handler_factory(calls))
|
||||
real_async_client = httpx.AsyncClient
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return real_async_client(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", factory)
|
||||
return calls
|
||||
|
||||
|
||||
async def test_mcp_list_tools(mcp_transport):
|
||||
ok, tools = await digest_service.mcp_list_tools("http://obsigate.test/mcp", "")
|
||||
assert ok is True
|
||||
assert tools == ["create_note", "search"]
|
||||
|
||||
|
||||
async def test_mcp_call_tool(mcp_transport):
|
||||
ok, message = await digest_service.mcp_call_tool(
|
||||
"http://obsigate.test/mcp", "", "create_note",
|
||||
{"title": "T", "content": "C"},
|
||||
)
|
||||
assert ok is True
|
||||
assert "note créée" in message
|
||||
call = next(c for c in mcp_transport if isinstance(c, dict))
|
||||
assert call["name"] == "create_note"
|
||||
assert call["arguments"] == {"title": "T", "content": "C"}
|
||||
|
||||
|
||||
def test_digest_send_to_obsigate(client, fake_gitea, monkeypatch, mcp_transport):
|
||||
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
|
||||
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_TOOL", "create_note")
|
||||
token = csrf_from(client)
|
||||
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
assert "✅ Digest envoyé (obsigate)" in r.text
|
||||
assert models.get_kv("digest_last_result")
|
||||
|
||||
|
||||
def test_digest_send_requires_configuration(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
assert "❌ ObsiGate incomplet" in r.text
|
||||
|
||||
|
||||
def test_digest_probe_lists_tools(client, fake_gitea, monkeypatch, mcp_transport):
|
||||
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
|
||||
token = csrf_from(client)
|
||||
r = client.post("/digest/probe", data={"csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
assert "create_note" in r.text
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Regression: real Gitea returns "pull_request": null on plain issues.
|
||||
|
||||
The 0.2.0–0.5.0 filter tested key *presence* ("pull_request" not in
|
||||
item), which classified every real issue as a PR and silently emptied
|
||||
every board against a real Gitea — while the test fake omitted the
|
||||
key entirely, so the suite stayed green. Found 2026-10-11 after a
|
||||
user report ("new issue does not appear on the board"): the live
|
||||
flowdeck board rendered 0 cards for 6 open issues.
|
||||
"""
|
||||
|
||||
from app.main import _is_issue
|
||||
|
||||
|
||||
def test_plain_issue_with_null_pull_request_is_an_issue():
|
||||
assert _is_issue({"number": 1, "pull_request": None}) is True
|
||||
|
||||
|
||||
def test_pull_request_object_is_not_an_issue():
|
||||
assert _is_issue({"number": 2, "pull_request": {"url": "http://x/pr/2"}}) is False
|
||||
|
||||
|
||||
def test_missing_key_is_an_issue():
|
||||
assert _is_issue({"number": 3}) is True
|
||||
|
||||
|
||||
def test_board_renders_cards_from_realistic_payload(client):
|
||||
# The shared fake now mirrors the real payload (key always
|
||||
# present, null on plain issues): if the filter regresses to a
|
||||
# presence test, these cards vanish and this test goes red.
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche un" in r.text
|
||||
assert "Tâche focus" in r.text
|
||||
assert "PR déguisée" not in r.text
|
||||
Reference in New Issue
Block a user