13 Commits
Author SHA1 Message Date
Spark 05b8c506e0 chore(release): Kanbante 0.6.0 — colonne QA, réconciliation par étiquettes, Wallboard
CI / test (push) Successful in 1m42s
2026-10-11 12:26:12 -04:00
Spark 943ecd6a6b test(0.6.0): colonne QA, adoption d'étiquettes, wallboard (10 tests, 98 au total) 2026-10-11 12:26:12 -04:00
Spark af59417d07 feat(wall): Wallboard plein écran /wall — issue #14 de la feuille de route
Rotation automatique entre projets actifs (30 s), compteurs des
six colonnes, cartes en vol avec âge, fermées du jour (journal),
horloge; rafraîchissement en direct par le flux SSE (rechargement,
aucune interaction à protéger) + filet de sécurité à 5 min.
?repo= fige un projet. Lien de navigation ajouté partout.
2026-10-11 12:26:12 -04:00
Spark a358c45d73 feat(colonnes): colonne QA entre En cours et Révision + réconciliation par étiquettes
- KANBAN_COLUMNS gagne 'qa' (🧪, #06b6d4, étiquette kanban:qa);
  Focus inclut QA; dashboard et board affichent les 6 colonnes.
- _adopt_label_columns: une étiquette kanban:* unique qui diverge
  de l'état local fait adopter sa colonne (board + focus), avec
  événement journalisé source 'gitea'. C'est ce qui permet de
  piloter le board depuis Gitea même.
2026-10-11 12:26:12 -04:00
Spark b7ddc10971 chore(release): Kanbante 0.5.2 — correctif indice de colonne vide
CI / test (push) Successful in 1m41s
2026-10-11 12:08:28 -04:00
Spark 542ed70c1a fix(board): l'indice « Glissez une carte ici » suit enfin le contenu des colonnes
Rendu par le serveur pour les colonnes vides au chargement, il
n'était jamais retiré après un dépôt (signalé par Bruno). Nouvelle
fonction refreshEmptyHints() appelée après chaque déplacement et
après chaque retour arrière: indice retiré si la colonne contient
une carte, rétabli si elle devient vide. SortableJS restreint aux
cartes (draggable: '.kanban-card'). Logique vérifiée par mini-test
DOM (4 scénarios) en plus de node --check.
2026-10-11 12:08:28 -04:00
Spark 52c41ead91 chore(release): Kanbante 0.5.1 — correctif boards vides contre vrai Gitea
CI / test (push) Successful in 1m41s
2026-10-11 11:42:23 -04:00
Spark f40d4c817b test(régression): le fake reproduit le payload Gitea réel (pull_request: null)
Le faux omettait la clé pull_request sur les issues, ce qui a
masqué le bug de la 0.5.1 pendant que 84 tests restaient verts.
Clé désormais toujours présente (null = issue, objet = PR) +
4 tests dédiés dans test_regression_gitea_payload.py.
2026-10-11 11:42:23 -04:00
Spark 71881514c9 fix(board): les issues réelles Gitea n'étaient pas des PR — boards vides
Gitea 1.27.3 renvoie "pull_request": null sur les issues ordinaires:
_is_issue testait la présence de la clé et classait chaque vraie
issue comme un pull request. Board, Focus et briefing affichaient
zéro carte contre un vrai Gitea (le dashboard, basé sur les
compteurs de dépôts, semblait normal, d'où le signalement
« la nouvelle issue n'apparaît pas »). Le filtre teste maintenant
la valeur. Vérifié en réel: board bruno/flowdeck 0 -> 6 cartes.
2026-10-11 11:42:23 -04:00
Spark 7d2faf027d chore(release): Kanbante 0.5.0
CI / test (push) Successful in 1m45s
Version, CHANGELOG (palier 3 points 7-9 + board) et README
(fonctionnalités, variables ntfy/briefing, nouvelles routes,
branchement d'un client MCP).
2026-10-11 11:00:53 -04:00
Spark b99e87ccca test(palier-3): 19 tests — recherche, mes issues, briefing, MCP, étiquettes (84 verts)
FakeGitea: get_user, search_issues (filtres q/état/assigné, PR
exclues, repository joint), delete_label, forget_label_cache;
issue 1 assignée à bruno pour /my.
2026-10-11 11:00:53 -04:00
Spark 6887d631ea feat(board): étiquettes gérables, notes repliable/redimensionnable/auto-save
- modale Étiquettes: liste, création (nom + couleur + palette),
  suppression via DELETE (CSRF par en-tête); panneau détail:
  bascules d'étiquettes par issue avec mise à jour immédiate des
  chips de la carte; _issue_payload expose les ids d'étiquettes
- API étiquettes dans main (routes repos + toggle issue)
- notes: bouton replier/déplier et largeur par glisser, persistés
  par projet (localStorage), sauvegarde automatique débouncée
  avec statut, formulaire repris par board.js (HTMX retiré du board)
- nouvelles pages search/my/briefing; navigation enrichie partout
  (Mes issues, Briefing, Recherche); CSS associé
2026-10-11 11:00:53 -04:00
Spark a788db7b1f feat(palier-3): recherche globale, mes issues, briefing ntfy, MCP lecture
- client: search_issues (/repos/issues/search, type=issues,
  assigned_by), delete_label + forget_label_cache; models: kv_store
- /search groupe par projet avec colonne Kanban; /my = issues
  ouvertes assignées à l'utilisateur du jeton (get_user)
- briefing: _briefing_data (focus, âgées, milestones <= 7 j,
  activité journal), page /briefing, /api/briefing, envoi ntfy
  manuel + planificateur lifespan à KANBANTE_BRIEFING_TIME
  (kv briefing_last_sent, pas de doublon); app/briefing.py =
  format texte + envoi ntfy (en-têtes ASCII)
- MCP: POST /mcp JSON-RPC (initialize, tools/list, tools/call,
  ping, notifications 202), 7 outils en lecture seule, GET /mcp
  de découverte; erreurs -32601/-32700, isError pour les outils
- focus refactoré en _collect_focus_groups réutilisé par le
  briefing et l'outil MCP get_focus
2026-10-11 11:00:53 -04:00
24 changed files with 2310 additions and 47 deletions
+99
View File
@@ -1,5 +1,104 @@
# Changelog
## 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
+44 -3
View File
@@ -5,7 +5,7 @@
## 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
@@ -14,7 +14,14 @@
- 🗂️ **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 révision, tous projets confondus (`/focus`)
- 🎯 **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
- 🔀 **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
@@ -101,6 +108,10 @@ sudo systemctl enable --now docker
| `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 |
## Sécurité
@@ -142,7 +153,8 @@ 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 | `/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 |
@@ -153,6 +165,35 @@ kanbante/
| 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)
+71
View File
@@ -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
+17 -1
View File
@@ -28,6 +28,14 @@ Environment variables:
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.
"""
import os
@@ -36,7 +44,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.4.0"
APP_VERSION = "0.6.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -88,6 +96,10 @@ class Settings:
aging_days: int = 7
webhook_secret: str = ""
sync_labels: bool = True
ntfy_url: str = ""
ntfy_topic: str = ""
ntfy_token: str = ""
briefing_time: str = ""
@property
def auth_enabled(self) -> bool:
@@ -113,4 +125,8 @@ def get_settings() -> Settings:
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", ""),
)
+35 -2
View File
@@ -172,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)
@@ -269,6 +271,37 @@ class GiteaClient:
)
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(
+726 -11
View File
@@ -34,11 +34,13 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
from app.models import (
KANBAN_COLUMNS,
column_entry_times,
get_board_state,
get_events,
get_kv,
get_metrics,
get_notes,
get_stats,
@@ -48,6 +50,7 @@ from app.models import (
ping_db,
reconcile_board_state,
set_column_order,
set_kv,
set_notes,
)
@@ -61,6 +64,7 @@ COLUMN_LABELS = {
"backlog": "📥 Backlog",
"todo": "📋 À faire",
"in_progress": "🚧 En cours",
"qa": "🧪 QA",
"review": "🔍 Révision",
"done": "✅ Terminé",
}
@@ -69,11 +73,16 @@ COLUMN_COLORS = {
"backlog": "#6b7280",
"todo": "#3b82f6",
"in_progress": "#f59e0b",
"qa": "#06b6d4",
"review": "#8b5cf6",
"done": "#10b981",
}
FOCUS_COLUMNS = ["in_progress", "review"]
FOCUS_COLUMNS = ["in_progress", "qa", "review"]
# Columns mirrored to Gitea as kanban:<column> labels (backlog and
# done carry none: done is the closed state itself).
LABELLED_COLUMNS = ("todo", "in_progress", "qa", "review")
EVENT_ICONS = {
"created": "➕",
@@ -86,6 +95,33 @@ EVENT_ICONS = {
# ── App setup ──────────────────────────────────────────────
async def _briefing_scheduler() -> None:
"""Send the daily briefing to ntfy at KANBANTE_BRIEFING_TIME.
Checks once a minute; the kv store remembers the last day sent so
restarts never double-send.
"""
while True:
try:
settings = get_settings()
if settings.briefing_time and ntfy_configured(settings):
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if (
now.strftime("%H:%M") >= settings.briefing_time
and get_kv("briefing_last_sent") != today
):
data = await _briefing_data()
if await send_ntfy(settings, format_briefing_text(data)):
set_kv("briefing_last_sent", today)
logger.info("Daily briefing sent to ntfy (%s)", today)
except asyncio.CancelledError:
raise
except Exception as exc: # never let the scheduler die silently
logger.warning("Briefing scheduler error: %s", exc)
await asyncio.sleep(60)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
@@ -102,7 +138,9 @@ async def lifespan(app: FastAPI):
logger.warning(
"GITEA_TOKEN is not set: only public Gitea content will be visible."
)
scheduler = asyncio.create_task(_briefing_scheduler())
yield
scheduler.cancel()
await gitea.aclose()
@@ -291,8 +329,15 @@ def classify_repos(
def _is_issue(item: dict) -> bool:
"""Gitea's issues endpoint also returns PRs; they carry this key."""
return "pull_request" not in item
"""True for issues, False for pull requests.
Gitea's issues endpoint also returns PRs. A PR carries a
``pull_request`` *object*; a plain issue carries the same key
with value ``null`` (verified against Gitea 1.27.3). Testing key
presence therefore classifies every real issue as a PR and
empties the board — test the value's truthiness instead.
"""
return not item.get("pull_request")
def _merge_issues_with_board(
@@ -314,6 +359,44 @@ def _merge_issues_with_board(
return columns
def _adopt_label_columns(
full_name: str, open_issues: list[dict], board_state: dict
) -> None:
"""Adopt the column named by an issue's kanban:* label, if any.
Labels are the Gitea-visible mirror of columns (written on every
drag). If the label changed outside Kanbante — edited in Gitea,
or set by another tool — the board follows it and the move is
journaled (source "gitea"). Rules: exactly one kanban:* label
(zero or several = no opinion), target must be a labelled column,
and label synchronisation must be enabled.
"""
if not get_settings().sync_labels:
return
placement: dict[int, str] = {}
for col_name, cards in board_state.items():
for card in cards:
placement[card["issue_number"]] = col_name
for issue in open_issues:
names = [
str(label.get("name", "")) for label in issue.get("labels") or []
]
kanban = [n for n in names if n.startswith("kanban:")]
if len(kanban) != 1:
continue
target = kanban[0].split(":", 1)[1]
if target not in LABELLED_COLUMNS:
continue
current = placement.get(issue["number"], "backlog")
if current != target:
move_issue(full_name, issue["number"], target)
log_event(
full_name, issue["number"], "moved",
from_column=current, to_column=target, source="gitea",
)
placement[issue["number"]] = target
def _split_full_name(repo_full_name: str) -> tuple[str, str] | None:
owner, sep, repo = repo_full_name.partition("/")
if not sep or not owner or not repo or "/" in repo:
@@ -366,6 +449,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
# A kanban:* label changed outside Kanbante wins over local state.
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
# Milestone view: filter the board to one milestone (sprint-style),
# with its deadline and completion progress.
milestones: list[dict] = []
@@ -471,9 +557,8 @@ def _card_ages(
return ages
@app.get("/focus", response_class=HTMLResponse)
async def focus(request: Request):
"""Cross-project view: everything in progress or in review."""
async def _collect_focus_groups() -> list[dict]:
"""In-progress / in-review cards across all active repos."""
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
active = active[:100]
@@ -492,6 +577,7 @@ async def focus(request: Request):
logger.warning("focus: repo fetch failed: %s", result)
continue
full_name, open_issues = result
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
columns = _merge_issues_with_board(open_issues, get_board_state(full_name))
cards = [
{"issue": issue, "column": col}
@@ -500,15 +586,322 @@ async def focus(request: Request):
]
if cards:
groups.append({"full_name": full_name, "cards": cards})
return groups
@app.get("/focus", response_class=HTMLResponse)
async def focus(request: Request):
"""Cross-project view: everything in progress or in review."""
return render(
request,
"focus.html",
groups=groups,
groups=await _collect_focus_groups(),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
def _group_issue_results(issues: list[dict]) -> list[dict]:
"""Group search results by repo, annotated with the board column."""
by_repo: dict[str, list[dict]] = {}
for issue in issues:
full_name = (issue.get("repository") or {}).get("full_name", "?")
by_repo.setdefault(full_name, []).append(issue)
groups = []
for full_name in sorted(by_repo):
state = get_board_state(full_name) if "/" in full_name else {}
column_of = {
card["issue_number"]: column
for column, cards in state.items()
for card in cards
}
rows = []
for issue in by_repo[full_name]:
if issue.get("state") == "open":
column = column_of.get(issue["number"], "backlog")
else:
column = "done"
rows.append({"issue": issue, "column": column})
groups.append({"full_name": full_name, "rows": rows})
return groups
WALL_COLUMNS = ["in_progress", "qa", "review", "todo"]
async def _collect_wall_data(only_repo: str | None) -> list[dict]:
"""Slides for the wallboard: one per active project.
Each slide carries the column counts, the cards in flight
(in progress / QA / review / todo, in flow order) with their age
in column, and how many cards were closed today (journal).
"""
if only_repo:
if _split_full_name(only_repo) is None:
return []
known = await gitea.search_repos()
match = [r for r in known if r.get("full_name") == only_repo]
if not match:
return []
repos = match
else:
all_repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(all_repos)
repos = active[:12]
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
closed_today: dict[str, int] = {}
for event in get_events(limit=500):
if event["event_type"] == "closed" and str(
event["created_at"]
).startswith(today):
closed_today[event["repo_full_name"]] = (
closed_today.get(event["repo_full_name"], 0) + 1
)
slides = []
for repo in repos:
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="open")
except Exception as exc:
logger.debug("wall: issues failed for %s: %s", full_name, exc)
continue
open_issues = [i for i in items if _is_issue(i)]
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
board_state = get_board_state(full_name)
columns = _merge_issues_with_board(open_issues, board_state)
ages = _card_ages(full_name, open_issues, board_state)
aging_days = get_settings().aging_days
cards = []
for col in WALL_COLUMNS:
for issue in columns[col]:
age = ages.get(issue["number"]) or {}
days = age.get("days")
cards.append(
{
"number": issue["number"],
"title": issue.get("title", ""),
"column": col,
"column_label": COLUMN_LABELS[col],
"age_days": days,
"aging": days is not None and days >= aging_days,
}
)
slides.append(
{
"full_name": full_name,
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
"open_total": len(open_issues),
"cards": cards[:9],
"closed_today": closed_today.get(full_name, 0),
}
)
return slides
@app.get("/wall", response_class=HTMLResponse)
async def wall(request: Request, repo: str = ""):
"""Wallboard: full-screen, auto-rotating, live project display."""
slides = await _collect_wall_data(repo or None)
return render(
request,
"wall.html",
slides=slides,
single=bool(repo),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
columns=KANBAN_COLUMNS,
)
@app.get("/search", response_class=HTMLResponse)
async def search_page(request: Request, q: str = ""):
"""Global issue search across every repo (Gitea search API)."""
q = q.strip()
groups: list[dict] = []
total = 0
if q:
results = await gitea.search_issues(q=q, state="all")
total = len(results)
groups = _group_issue_results(results)
return render(
request,
"search.html",
q=q,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
@app.get("/my", response_class=HTMLResponse)
async def my_issues(request: Request):
"""Open issues assigned to the Gitea token's owner, all repos."""
user = None
groups: list[dict] = []
total = 0
try:
user = await gitea.get_user()
except Exception as exc:
logger.debug("get_user failed for /my: %s", exc)
if user and user.get("login"):
results = await gitea.search_issues(
state="open", assigned_by=user["login"]
)
total = len(results)
groups = _group_issue_results(results)
return render(
request,
"my.html",
user=user,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
# ── Briefing ───────────────────────────────────────────────
async def _briefing_data() -> dict:
"""Everything the daily briefing needs, gathered in one dict."""
settings = get_settings()
focus_groups = await _collect_focus_groups()
repos = await gitea.search_repos()
active, _i, _a = classify_repos(repos)
soon = datetime.now(timezone.utc) + timedelta(days=7)
aging: list[dict] = []
milestones_due: list[dict] = []
async def _per_repo(repo: dict):
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
items = await gitea.get_repo_issues(owner, name, state="open")
open_issues = [i for i in items if _is_issue(i)]
ages = _card_ages(full_name, open_issues, get_board_state(full_name))
board = get_board_state(full_name)
column_of = {
card["issue_number"]: column
for column, cards in board.items()
for card in cards
}
repo_aging = [
{
"repo": full_name,
"number": issue["number"],
"title": issue["title"],
"days": ages[issue["number"]]["days"],
"column_label": COLUMN_LABELS[
column_of.get(issue["number"], "backlog")
],
}
for issue in open_issues
if issue["number"] in ages
and ages[issue["number"]]["days"] >= settings.aging_days
]
repo_milestones = []
try:
for ms in await gitea.get_milestones(owner, name):
due = _parse_datetime(ms.get("due_on"))
if due and due <= soon and (ms.get("open_issues") or 0) > 0:
repo_milestones.append(
{
"repo": full_name,
"title": ms.get("title", ""),
"due_on": str(ms["due_on"])[:10],
"open": ms.get("open_issues") or 0,
}
)
except Exception as exc:
logger.debug("briefing milestones failed for %s: %s", full_name, exc)
return repo_aging, repo_milestones
results = await asyncio.gather(
*(_per_repo(r) for r in active[:100]), return_exceptions=True
)
for result in results:
if isinstance(result, Exception):
logger.warning("briefing: repo fetch failed: %s", result)
continue
repo_aging, repo_milestones = result
aging.extend(repo_aging)
milestones_due.extend(repo_milestones)
aging.sort(key=lambda c: -c["days"])
metrics_24h = get_metrics(window_days=1)
metrics_30d = get_metrics(window_days=30)
return {
"date": datetime.now().strftime("%d/%m/%Y"),
"focus_total": sum(len(g["cards"]) for g in focus_groups),
"focus_groups": [
{
"full_name": g["full_name"],
"count": len(g["cards"]),
"titles": [c["issue"]["title"] for c in g["cards"]],
}
for g in focus_groups
],
"aging": aging[:10],
"milestones_due": milestones_due[:5],
"activity_24h": {
**metrics_24h,
"avg_cycle_days": metrics_30d["avg_cycle_days"],
},
}
@app.get("/briefing", response_class=HTMLResponse)
async def briefing_page(request: Request):
"""The daily briefing, readable on the web before it is pushed."""
settings = get_settings()
data = await _briefing_data()
return render(
request,
"briefing.html",
data=data,
text=format_briefing_text(data),
ntfy_configured=ntfy_configured(settings),
briefing_time=settings.briefing_time,
last_sent=get_kv("briefing_last_sent"),
)
@app.get("/api/briefing")
async def api_briefing():
data = await _briefing_data()
return {"ok": True, "briefing": data, "text": format_briefing_text(data)}
@app.post("/api/briefing/send")
async def api_briefing_send(request: Request, csrf_token: str = Form("")):
"""Send the briefing to the configured ntfy topic, right now."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
if not ntfy_configured(settings):
return JSONResponse(
{
"ok": False,
"error": "ntfy is not configured "
"(KANBANTE_NTFY_URL / KANBANTE_NTFY_TOPIC)",
},
status_code=400,
)
data = await _briefing_data()
sent = await send_ntfy(settings, format_briefing_text(data))
if not sent:
return JSONResponse(
{"ok": False, "error": "ntfy server rejected or unreachable"},
status_code=502,
)
set_kv("briefing_last_sent", datetime.now().strftime("%Y-%m-%d"))
return {"ok": True, "sent": True}
# ── Webhooks + live stream ─────────────────────────────────
def _apply_external_transition(
@@ -715,9 +1108,7 @@ async def api_move(
if settings.sync_labels and from_column != to_column:
try:
desired = (
f"kanban:{to_column}"
if to_column in ("todo", "in_progress", "review")
else None
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
)
issue = await gitea.get_issue(owner, repo, issue_number)
current = [
@@ -802,7 +1193,7 @@ def _issue_payload(issue: dict) -> dict:
"updated_at": issue.get("updated_at", ""),
"due_date": issue.get("due_date"),
"labels": [
{"name": l.get("name", ""), "color": l.get("color", "")}
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
"assignees": [
@@ -889,6 +1280,330 @@ async def api_metrics(repo: str = "", window: int = 30):
return {"ok": True, "metrics": get_metrics(repo or None, window)}
# ── Label management ───────────────────────────────────────
@app.get("/api/repos/{owner}/{repo}/labels")
async def api_list_labels(owner: str, repo: str):
"""All label definitions of a repo."""
labels = await gitea.get_labels(owner, repo)
return {
"ok": True,
"labels": [
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in labels
],
}
@app.post("/api/repos/{owner}/{repo}/labels")
async def api_create_label(
request: Request,
owner: str,
repo: str,
name: str = Form(...),
color: str = Form("7c5cfc"),
csrf_token: str = Form(""),
):
"""Create a label definition in the repo."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
name = name.strip()
color = color.strip().lstrip("#")
if not name or len(name) > 50:
return JSONResponse(
{"ok": False, "error": "Label name must be 1-50 chars"},
status_code=400,
)
if len(color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in color):
return JSONResponse(
{"ok": False, "error": "Color must be a 6-digit hex value"},
status_code=400,
)
label = await gitea.create_label(owner, repo, name, color)
gitea.forget_label_cache(owner, repo)
return JSONResponse(
{
"ok": True,
"label": {
"id": label.get("id"),
"name": label.get("name", name),
"color": label.get("color", color),
},
},
status_code=201,
)
@app.delete("/api/repos/{owner}/{repo}/labels/{label_id}")
async def api_delete_label(
request: Request, owner: str, repo: str, label_id: int
):
"""Delete a label definition (CSRF via the X-CSRF-Token header)."""
if not _csrf_valid(request, ""):
return _csrf_error()
await gitea.delete_label(owner, repo, label_id)
return {"ok": True, "deleted": label_id}
@app.post("/api/issues/{owner}/{repo}/{number}/labels")
async def api_toggle_issue_label(
request: Request,
owner: str,
repo: str,
number: int,
label_id: int = Form(...),
action: str = Form(...),
csrf_token: str = Form(""),
):
"""Add or remove one label on one issue; returns the new label set."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if action not in ("add", "remove"):
return JSONResponse(
{"ok": False, "error": "action must be 'add' or 'remove'"},
status_code=400,
)
if action == "add":
await gitea.add_issue_label(owner, repo, number, label_id)
else:
await gitea.remove_issue_label(owner, repo, number, label_id)
issue = await gitea.get_issue(owner, repo, number)
return {
"ok": True,
"labels": [
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
}
# ── MCP server (read-only tools over JSON-RPC) ─────────────
MCP_TOOLS = [
{
"name": "list_projects",
"description": "List the projects (Gitea repos) Kanbante tracks, grouped by activity.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_board",
"description": "Get the kanban board of one repo: columns with their cards.",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
},
"required": ["owner", "repo"],
},
},
{
"name": "get_issue",
"description": "Get one issue with its body and comments.",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
},
"required": ["owner", "repo", "number"],
},
},
{
"name": "get_focus",
"description": "Cards in progress or in review across all active projects.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "search_issues",
"description": "Search issues by text across all repos.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"]},
},
"required": ["query"],
},
},
{
"name": "get_activity",
"description": "Latest entries of the board event journal.",
"inputSchema": {
"type": "object",
"properties": {"limit": {"type": "integer"}},
},
},
{
"name": "get_metrics",
"description": "Throughput and cycle-time metrics from the journal.",
"inputSchema": {
"type": "object",
"properties": {"repo": {"type": "string"}},
},
},
]
async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one read-only MCP tool and return text content."""
if name == "list_projects":
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
lines = [f"Active projects ({len(active)}):"]
lines += [
f"- {r['full_name']} ({r.get('open_issues_count') or 0} open issues)"
for r in active
]
lines.append(
f"Inactive: {len(inactive)} - Archived: {len(archived)}"
)
return "\n".join(lines)
if name == "get_board":
owner, repo = args["owner"], args["repo"]
items = await gitea.get_repo_issues(owner, repo, state="all")
issues = [i for i in items if _is_issue(i)]
open_issues = [i for i in issues if i.get("state") == "open"]
columns = _merge_issues_with_board(open_issues, get_board_state(f"{owner}/{repo}"))
return json.dumps(
{
column: [
{
"number": i["number"],
"title": i["title"],
"labels": [l.get("name") for l in i.get("labels") or []],
}
for i in cards
]
for column, cards in columns.items()
},
ensure_ascii=False,
)
if name == "get_issue":
detail = await api_issue_detail(args["owner"], args["repo"], int(args["number"]))
return json.dumps(detail, ensure_ascii=False, default=str)
if name == "get_focus":
groups = await _collect_focus_groups()
return json.dumps(
[
{
"repo": g["full_name"],
"cards": [
{
"number": c["issue"]["number"],
"title": c["issue"]["title"],
"column": c["column"],
}
for c in g["cards"]
],
}
for g in groups
],
ensure_ascii=False,
)
if name == "search_issues":
results = await gitea.search_issues(
q=args["query"], state=args.get("state", "open")
)
return json.dumps(
[
{
"repo": (i.get("repository") or {}).get("full_name"),
"number": i["number"],
"title": i["title"],
"state": i["state"],
}
for i in results
],
ensure_ascii=False,
)
if name == "get_activity":
return json.dumps(
get_events(limit=int(args.get("limit", 50))), ensure_ascii=False
)
if name == "get_metrics":
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
raise ValueError(f"Unknown tool: {name}")
@app.get("/mcp")
async def mcp_info():
"""Discoverability for the MCP endpoint (the protocol is POST)."""
return {
"name": "kanbante",
"version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": True,
"tools": [t["name"] for t in MCP_TOOLS],
}
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
Read-only by design (Bruno's rule: cross-platform reads first,
writes go through approval flows elsewhere).
"""
try:
payload = await request.json()
except ValueError:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "Parse error"}},
status_code=400,
)
if isinstance(payload, list): # batch: not supported by this server
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32600, "message": "Batch requests not supported"}},
status_code=400,
)
method = payload.get("method", "")
rpc_id = payload.get("id")
if rpc_id is None: # notification: acknowledge silently
return Response(status_code=202)
params = payload.get("params") or {}
def _result(result):
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
def _error(code, message):
return {"jsonrpc": "2.0", "id": rpc_id,
"error": {"code": code, "message": message}}
if method == "initialize":
return _result(
{
"protocolVersion": params.get("protocolVersion", "2024-11-05"),
"capabilities": {"tools": {}},
"serverInfo": {"name": "kanbante", "version": APP_VERSION},
}
)
if method == "ping":
return _result({})
if method == "tools/list":
return _result({"tools": MCP_TOOLS})
if method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments") or {}
try:
text = await _mcp_call_tool(tool_name, arguments)
except KeyError as exc:
return _result(
{"content": [{"type": "text", "text": f"Missing argument: {exc}"}],
"isError": True}
)
except Exception as exc:
return _result(
{"content": [{"type": "text", "text": f"Tool error: {exc}"}],
"isError": True}
)
return _result({"content": [{"type": "text", "text": text}], "isError": False})
return _error(-32601, f"Method not found: {method}")
@app.post("/api/notes")
async def api_notes(
request: Request,
+23 -1
View File
@@ -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:
@@ -79,6 +79,11 @@ def init_db() -> None:
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);
"""
@@ -234,6 +239,23 @@ def reconcile_board_state(
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")
+4
View File
@@ -16,7 +16,11 @@
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
</nav>
</header>
+51 -23
View File
@@ -6,7 +6,6 @@
<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>
@@ -21,7 +20,10 @@
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
@@ -61,6 +63,7 @@
<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>
@@ -70,7 +73,7 @@
<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] }}">
@@ -123,21 +126,51 @@
{% endfor %}
</div>
<!-- Notes panel -->
<aside class="notes-panel">
<h3>📝 Notes du projet</h3>
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea.</p>
<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>
@@ -149,6 +182,10 @@
<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>
@@ -167,14 +204,5 @@
{% endif %}
</footer>
<script src="/static/js/live.js" defer></script>
<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>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
<!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="/wall" class="nav-pill">🖥️ Wallboard</a>
</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>
</body>
</html>
+5 -1
View File
@@ -16,7 +16,11 @@
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
</nav>
</header>
@@ -81,7 +85,7 @@
<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] }}">
+2
View File
@@ -14,7 +14,9 @@
<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">
+4
View File
@@ -16,7 +16,11 @@
<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="/wall" class="nav-pill">🖥️ Wallboard</a>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
+64
View File
@@ -0,0 +1,64 @@
<!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="/wall" class="nav-pill">🖥️ Wallboard</a>
</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>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
<!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="/wall" class="nav-pill">🖥️ Wallboard</a>
</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>
</body>
</html>
+70
View File
@@ -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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.4.0"
version = "0.6.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+222
View File
@@ -719,6 +719,154 @@ footer {
.activity-text { flex: 1; }
.card-date { color: var(--text-3); }
/* ── Search / my issues / briefing ──────────── */
.search-hero { display: flex; gap: 10px; margin-bottom: 18px; }
.search-hero input {
flex: 1;
padding: 13px 18px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 1rem;
outline: none;
}
.search-hero input:focus { border-color: var(--accent); box-shadow: var(--ring); }
.issue-results { display: flex; flex-direction: column; gap: 7px; }
.issue-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 0.9rem;
transition: border-color 0.15s;
}
.issue-row:hover { border-color: var(--border-strong); }
.issue-main { color: var(--text); flex: 1; min-width: 220px; }
.issue-main:hover { color: #a5c4fd; text-decoration: none; }
.col-chip {
padding: 2px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.briefing-tiles { margin-bottom: 18px; }
.briefing-card {
background: linear-gradient(180deg, var(--surface-2), var(--surface));
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
}
.briefing-card h2 { font-size: 1.02rem; margin-bottom: 12px; }
.briefing-text {
white-space: pre-wrap;
font-family: inherit;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text);
}
.briefing-card code, .empty-state code {
background: var(--surface-3);
padding: 1px 6px;
border-radius: 5px;
font-size: 0.82em;
}
/* ── Notes panel v2 ─────────────────────────── */
.notes-panel { position: relative; transition: width 0.18s ease; }
.notes-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.notes-head h3 { margin-bottom: 0; }
.notes-toggle { padding: 4px 10px; }
.notes-panel.collapsed { width: 54px !important; padding: 14px 10px; }
.notes-panel.collapsed .notes-body-wrap { display: none; }
.notes-panel.collapsed .notes-head h3 { display: none; }
.notes-panel.collapsed .notes-head { justify-content: center; }
.notes-resize {
position: absolute;
left: -3px; top: 0;
width: 7px; height: 100%;
cursor: col-resize;
z-index: 5;
}
.notes-resize:hover { background: rgba(124, 92, 252, 0.35); }
.notes-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; }
.notes-foot .btn-save { margin-top: 0; }
.notes-status { font-size: 0.76rem; color: var(--text-3); }
/* ── Label manager + toggles ────────────────── */
.modal {
position: fixed; inset: 0;
background: rgba(4, 6, 10, 0.6);
backdrop-filter: blur(3px);
z-index: 300;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 8vh 16px 16px;
}
.modal[hidden] { display: none; }
.modal-box {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
width: min(560px, 94vw);
max-height: 80vh;
overflow-y: auto;
padding: 22px;
animation: drawer-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.modal-box h2 { font-size: 1.1rem; }
.label-list { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; }
.label-row {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.label-del { font-size: 0.78rem; padding: 5px 10px; }
.label-create-form { display: flex; gap: 8px; align-items: center; }
.label-create-form input[type="text"] {
flex: 1;
padding: 10px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
outline: none;
}
.label-create-form input[type="text"]:focus { border-color: var(--accent); box-shadow: var(--ring); }
.label-create-form input[type="color"] {
width: 44px; height: 40px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
cursor: pointer;
padding: 3px;
}
.label-swatches { display: flex; gap: 6px; margin-top: 10px; }
.swatch {
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.25);
cursor: pointer;
padding: 0;
}
.swatch:hover { transform: scale(1.15); }
.detail-labels-manage { margin: 4px 0 10px; }
.label-toggles { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.label-toggle { cursor: pointer; opacity: 0.45; transition: opacity 0.15s, transform 0.1s; }
.label-toggle:hover { opacity: 0.85; }
.label-toggle.on { opacity: 1; box-shadow: 0 0 0 1.5px currentColor inset; }
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
.hero { flex-direction: column; align-items: flex-start; }
@@ -745,3 +893,77 @@ footer {
scroll-behavior: auto !important;
}
}
/* ── Wallboard (0.6.0) : affichage plein écran, lisible à 3 mètres ── */
.wall-body {
background: #0b1220;
color: #e5ecf5;
margin: 0;
font-family: inherit;
}
.wall-stage { position: relative; min-height: 100vh; }
.wall-slide {
display: none;
min-height: 100vh;
box-sizing: border-box;
padding: 4vh 4vw;
flex-direction: column;
gap: 3vh;
}
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
.wall-head {
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
}
.wall-kicker { font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase; color: #8fa3bf; }
.wall-project { font-size: clamp(2.2rem, 5.2vw, 4.2rem); margin: 0.4rem 0 0; line-height: 1.05; }
.wall-clock { text-align: right; }
.wall-time { font-size: clamp(2rem, 4.5vw, 3.6rem); font-weight: 800; line-height: 1; }
.wall-date { color: #8fa3bf; font-size: 1.15rem; margin-top: 0.35rem; text-transform: capitalize; }
.wall-rotation { color: #5b6b84; margin-top: 0.4rem; font-size: 1rem; }
.wall-counts {
display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px;
}
.wall-count {
border: 1.5px solid var(--col);
border-top: 6px solid var(--col);
border-radius: 14px;
padding: 14px 10px 12px;
text-align: center;
background: rgba(255, 255, 255, 0.03);
}
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.wall-card {
display: flex; align-items: center; gap: 18px;
background: rgba(255, 255, 255, 0.045);
border-radius: 14px;
padding: 16px 20px;
font-size: clamp(1.15rem, 2.1vw, 1.7rem);
}
.wall-card-col {
border: 1.5px solid var(--col); color: var(--col);
border-radius: 999px; padding: 4px 14px;
font-size: 0.62em; font-weight: 700; white-space: nowrap;
}
.wall-card-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wall-card-age { color: #fbbf24; font-weight: 700; white-space: nowrap; }
.wall-card-age.hot { color: #f87171; }
.wall-idle { font-size: 1.5rem; color: #8fa3bf; }
.wall-foot {
margin-top: auto;
display: flex; gap: 36px; flex-wrap: wrap;
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
}
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
.wall-empty {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px; text-align: center;
color: #aebdd2; padding: 24px;
}
.wall-empty h1 { color: #e5ecf5; font-size: 2.6rem; margin: 0; }
.wall-empty code { background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 6px; }
@media (max-width: 900px) {
.wall-counts { grid-template-columns: repeat(3, 1fr); }
}
+232
View File
@@ -28,12 +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);
@@ -69,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);
});
}
@@ -115,6 +118,25 @@ 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;
@@ -138,6 +160,161 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// ── 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;
@@ -215,6 +392,7 @@ document.addEventListener('DOMContentLoaded', () => {
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 || '#';
@@ -226,6 +404,60 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
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';
+42
View File
@@ -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);
})();
+36 -4
View File
@@ -48,16 +48,17 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
"body": f"Corps de l'issue {number} en **markdown**.",
"state": state,
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": None,
"assignees": [],
"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
@@ -109,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")
+240
View File
@@ -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
+146
View File
@@ -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
+34
View File
@@ -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