11 Commits
Author SHA1 Message Date
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
Spark 2d89db8213 chore(release): Kanbante 0.4.0
CI / test (push) Successful in 1m40s
Version, CHANGELOG (palier 2 + refonte) et README (fonctionnalités,
KANBANTE_WEBHOOK_SECRET / KANBANTE_SYNC_LABELS, routes webhook/stream/
milestone, procédure d'activation du webhook dans Gitea).
2026-10-11 10:23:50 -04:00
Spark 45283f0f8a test(palier-2): 13 tests — webhooks, SSE, étiquettes, milestones (65 verts)
FakeGitea: labels (repo_labels/label_ops), milestones (Sprint 1 sur
issues 1 et 3), invalidate_cache compteur. Le flux SSE est testé au
niveau du générateur: les clients de test HTTP ne ferment pas
proprement un flux infini (vérifié empiriquement); le bout-en-bout
HTTP a été validé en smoke test réel (curl + webhook).
2026-10-11 10:23:50 -04:00
Spark dbda3bcf5a refonte(ui): design system moderne et intuitif sur les 5 pages
- en-tête vitré sticky, navigation en pastilles avec état actif,
  logo en tuile dégradée, fil d'Ariane projet
- dashboard: hero + tuiles de synthèse (actifs, fermées/déplacements
  30 j, cycle moyen), barre de progression par projet, états vides
  guidés, raccourci clavier /
- board: sélecteur de milestones + bandeau de progression, colonnes
  à défilement indépendant, placeholders de colonnes vides, hint
  sur le panneau de notes, toast et drawer animés
- live.js: recharge douce conditionnelle (visible, pas de drawer,
  pas de saisie, pas de drag) sinon pastille d'actualisation
- accessibilité: anneau de focus visible, prefers-reduced-motion,
  responsive retravaillé; board.js: drapeau de drag partagé avec
  live.js + raccourci / vers le filtre
2026-10-11 10:23:50 -04:00
Spark 23e63c7f6e feat(palier-2): webhooks Gitea signés, flux SSE, étiquettes, milestones
- POST /webhooks/gitea: HMAC-SHA256 si KANBANTE_WEBHOOK_SECRET,
  applique fermetures/réouvertures externes de façon idempotente
  (l'écho de nos propres appels est un no-op, journal source=webhook),
  invalide le cache, diffuse sur le hub SSE
- GET /api/stream (SSE) + LiveHub; générateur testable séparément
- synchro d'étiquettes kanban:* à chaque déplacement (ensure/add/
  remove granulaires, étiquettes utilisateur intactes, flag
  KANBANTE_SYNC_LABELS, échec rapporté label_sync sans casser le move)
- board ?milestone=<titre|id>: filtre, échéance, progression;
  client: get_milestones, labels, invalidate_cache public
- dashboard: métriques 30 j du journal exposées au template
2026-10-11 10:23:50 -04:00
23 changed files with 3051 additions and 302 deletions
+105
View File
@@ -1,5 +1,110 @@
# Changelog
## 0.5.1 — 2026-10-11
### Correctif critique
- **Les boards étaient vides contre un vrai Gitea** : l'API Gitea
(1.27.3 vérifié) renvoie la clé `"pull_request": null` sur les
issues ordinaires ; le filtre `_is_issue` testait la *présence* de
la clé (`"pull_request" not in item`), donc chaque vraie issue
était classée « pull request » et écartée. Board, vue Focus et
briefing ne montraient aucune carte (le dashboard, qui lit les
compteurs des dépôts, semblait normal). Corrigé : le filtre teste
la *valeur* (`not item.get("pull_request")`).
- Le faux Gitea des tests omettait la clé, ce qui masquait le bug :
il reproduit désormais fidèlement le payload réel (clé toujours
présente, `null` sur les issues), plus 4 tests de régression dédiés
(88 tests au total). Smoke test réel : le board `bruno/flowdeck`
passe de 0 à 6 cartes, dont l'issue de test « test d'ajout ! ».
## 0.5.0 — 2026-10-11
« Palier 3 » (points 7 à 9) + board enrichi.
### Ajouts
- **Recherche globale** (`/search`) : recherche d'issues dans tous les
projets via l'API Gitea, résultats groupés par projet avec la colonne
Kanban de chaque issue. **Mes issues** (`/my`) : les issues ouvertes
assignées à l'utilisateur du jeton, tous projets confondus.
- **Briefing quotidien** : page `/briefing` (encours, activité 24 h,
cartes vieillissantes, milestones proches), `GET /api/briefing`, et
envoi **ntfy** manuel (`POST /api/briefing/send`) ou automatique à
`KANBANTE_BRIEFING_TIME` via un planificateur interne (mémoire
d'envoi en base, aucun doublon après redémarrage).
- **Serveur MCP** (lecture seule) : JSON-RPC 2.0 sur `POST /mcp`
(initialize, tools/list, tools/call, ping) avec 7 outils —
`list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Aucune écriture,
conformément au principe « lectures d'abord ». `GET /mcp` décrit le
serveur.
- **Gestion des étiquettes sur le board** : modale de gestion
(lister / créer avec palette de couleurs / supprimer les étiquettes
du dépôt dans Gitea) et, dans le panneau détail, ajout / retrait
d'étiquettes sur l'issue en un clic, avec mise à jour immédiate de
la carte. API : `GET|POST /api/repos/{o}/{r}/labels`,
`DELETE /api/repos/{o}/{r}/labels/{id}`,
`POST /api/issues/{o}/{r}/{n}/labels`.
### Améliorations
- **Panneau Notes du projet** : repliable (bouton ⤢, état mémorisé par
projet), **largeur ajustable** par glisser (mémorisée), et
**sauvegarde automatique** après 1,2 s sans frappe avec indicateur
d'état (le bouton manuel reste). Le formulaire quitte HTMX pour le
même `board.js` que le reste du board.
- Navigation unifiée enrichie sur toutes les pages : Mes issues,
Briefing, Recherche.
### Tests
- 19 tests ajoutés (84 au total) : recherche et mes issues, briefing
(page, API, envoi ntfy, format), protocole MCP, API étiquettes,
marqueurs du board. Un doublon de déclaration JS attrapé par
`node --check` avant livraison.
## 0.4.0 — 2026-10-11
« Palier 2 » : temps réel et visible dans Gitea — plus une refonte
complète de l'interface.
### Ajouts
- **Webhooks Gitea + rafraîchissement en direct** : `POST /webhooks/gitea`
(signature HMAC-SHA256 vérifiée quand `KANBANTE_WEBHOOK_SECRET` est
défini) invalide le cache de lecture, applique les fermetures /
réouvertures faites dans Gitea (idempotent : l'écho de nos propres
appels ne double jamais le journal) et pousse un événement SSE sur
`GET /api/stream`. Le navigateur se recharge en douceur quand c'est
sûr (onglet visible, pas de panneau ouvert, pas de saisie en cours,
pas de drag) ; sinon une pastille « Nouvelle activité » propose
l'actualisation.
- **Synchro par étiquettes** : les colonnes intermédiaires sont
reflétées dans Gitea par des étiquettes `kanban:todo`,
`kanban:in_progress`, `kanban:review` (créées au besoin, ajout /
retrait granulaires — les étiquettes de l'utilisateur ne sont jamais
touchées ; backlog et done = aucune étiquette). Désactivable :
`KANBANTE_SYNC_LABELS=0`.
- **Boards par milestone** : `GET /board/{owner}/{repo}?milestone=<titre|id>`
filtre le board, avec bandeau d'échéance et progression
(issues fermées du milestone / total). Sélecteur de milestones
au-dessus du board.
### Interface (refonte complète)
- Nouveau design system : en-tête vitré sticky avec navigation en
pastilles (état actif), halos d'ambiance, cartes projets qui se
soulèvent au survol, barres de progression, coins plus doux,
anneau de focus visible partout, `prefers-reduced-motion` respecté.
- Dashboard : hero avec tuiles de synthèse (projets actifs, issues
fermées et déplacements sur 30 jours, temps de cycle moyen — depuis
le journal), états vides guidés pour chaque onglet, raccourci `/`
pour la recherche (aussi sur le filtre du board).
- Board : pastille « live » en cas d'activité externe, colonnes à
défilement indépendant, placeholders de colonnes vides, panneau de
notes explicité, drawer animé.
- Cohérence : même navigation sur les 5 pages, responsive retravaillé.
### Tests
- 13 tests ajoutés (65 au total) : hub SSE et générateur de flux,
webhooks (transition, idempotence, signature, JSON invalide),
synchro d'étiquettes, boards par milestone, marqueurs de la refonte.
## 0.3.0 — 2026-10-11
« Palier 1 » : voir ce qui se passe sur les boards.
+59
View File
@@ -9,10 +9,17 @@
- 🎯 **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
- ⚡ **Temps réel** — un webhook Gitea invalide le cache et pousse un événement SSE : le board ouvert se met à jour tout seul (en douceur, sans interrompre une saisie ni un drag)
- 🏷️ **Visible dans Gitea** — les colonnes intermédiaires sont reflétées par des étiquettes `kanban:*` sur les issues, et un board peut se filtrer **par milestone** (échéance + progression façon sprint)
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
- 🔍 **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
@@ -97,6 +104,12 @@ sudo systemctl enable --now docker
| `KANBANTE_SYNC_GITEA` | `1` | `0` = les déplacements ne ferment/rouvrent plus les issues Gitea |
| `KANBANTE_WIP_LIMITS` | *(vide)* | Limites souples par colonne, ex. `in_progress:5,review:3` (affichage `n/limite`, rouge en dépassement, jamais bloquant) |
| `KANBANTE_AGING_DAYS` | `7` | Jours dans la même colonne avant le badge « ⏳ » sur une carte (rouge au double) |
| `KANBANTE_WEBHOOK_SECRET` | *(vide)* | Secret partagé du webhook Gitea ; si défini, la signature HMAC `X-Gitea-Signature` est exigée |
| `KANBANTE_SYNC_LABELS` | `1` | `0` = ne plus refléter les colonnes intermédiaires en étiquettes `kanban:*` dans Gitea |
| `KANBANTE_NTFY_URL` | *(vide)* | Base du serveur ntfy (ex. `https://ntfy.sh` ou auto-hébergé) pour le briefing |
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
## Sécurité
@@ -146,6 +159,52 @@ kanbante/
| POST | `/api/issues/{owner}/{repo}/{n}/comments` | Commenter l'issue dans Gitea |
| GET | `/api/events` | Derniers événements du journal (`?repo=`, `?limit=`) |
| GET | `/api/metrics` | Débit + temps de cycle moyen (`?repo=`, `?window=`) |
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
| GET | `/search?q=` | Recherche globale d'issues (tous projets, tous états) |
| GET | `/my` | Issues ouvertes assignées à l'utilisateur du jeton |
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
## Brancher un client MCP
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
les lectures d'abord). Configuration type (transport HTTP) :
```json
{
"mcpServers": {
"kanbante": {
"url": "https://<votre-hôte-kanbante>/mcp"
}
}
}
```
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
Kanbante est active, le client doit envoyer l'en-tête
`Authorization` correspondant.
## Activer le temps réel (webhook Gitea)
Dans chaque dépôt (ou au niveau d'une organisation) : **Réglages →
Webhooks → Ajouter un webhook → Gitea**, avec :
- **URL cible** : `https://<votre-hôte-kanbante>/webhooks/gitea`
- **Type de contenu** : `application/json`
- **Secret** : la même valeur que `KANBANTE_WEBHOOK_SECRET`
- **Déclencheur** : « Événements d'issue » (et « Commentaire d'issue »
si vous voulez aussi le rafraîchissement sur commentaires externes)
Dès lors, fermer ou rouvrir une issue dans Gitea met à jour les boards
Kanbante ouverts, sans rechargement manuel.
| POST | `/api/notes` | Sauvegarder des notes |
| GET | `/api/stats` | Statistiques JSON |
| GET | `/api/projects` | Projets JSON (actifs / inactifs / archivés) |
+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
+26 -1
View File
@@ -23,6 +23,19 @@ Environment variables:
warns, moves are never blocked.
KANBANTE_AGING_DAYS Days in the same column before a card is
flagged as aging (default 7; "stale" at 2x).
KANBANTE_WEBHOOK_SECRET Shared secret for the Gitea webhook
(POST /webhooks/gitea). When set, the
X-Gitea-Signature HMAC is enforced.
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
columns to Gitea labels named kanban:<col>.
KANBANTE_NTFY_URL Base URL of an ntfy server (e.g.
https://ntfy.sh or self-hosted).
KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing.
KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy.
KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which
the daily briefing is sent to ntfy.
Empty (default) = no automatic send,
the /briefing page stays available.
"""
import os
@@ -31,7 +44,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.3.0"
APP_VERSION = "0.5.1"
def _env_bool(name: str, default: bool) -> bool:
@@ -81,6 +94,12 @@ class Settings:
sync_gitea: bool = True
wip_limits: dict | None = None
aging_days: int = 7
webhook_secret: str = ""
sync_labels: bool = True
ntfy_url: str = ""
ntfy_topic: str = ""
ntfy_token: str = ""
briefing_time: str = ""
@property
def auth_enabled(self) -> bool:
@@ -104,4 +123,10 @@ def get_settings() -> Settings:
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""),
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
)
+98 -2
View File
@@ -120,6 +120,10 @@ class GiteaClient:
def _cache_clear(self) -> None:
self._cache.clear()
def invalidate_cache(self) -> None:
"""Drop every cached list response (called on webhook events)."""
self._cache.clear()
# ── API ────────────────────────────────────────────────
async def ping(self) -> bool:
@@ -168,8 +172,10 @@ class GiteaClient:
) -> list[dict]:
"""Get issues for a repo (paginated, cached).
Note: Gitea's issues endpoint also returns pull requests; each
PR carries a "pull_request" key and callers filter on it.
Note: Gitea's issues endpoint also returns pull requests; a
PR carries a non-null "pull_request" object, a plain issue
carries "pull_request": null — callers filter on truthiness
(see app.main._is_issue).
"""
key = f"issues:{owner}/{repo}:{state}"
cached = self._cache_get(key)
@@ -221,6 +227,96 @@ class GiteaClient:
r.raise_for_status()
return r.json()
# ── Labels (kanban:* column mirroring) ─────────────────
async def get_labels(self, owner: str, repo: str) -> list[dict]:
return await self._get_all_pages(f"/repos/{owner}/{repo}/labels")
async def create_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
r = await self.client.post(
f"/repos/{owner}/{repo}/labels",
json={"name": name, "color": color.lstrip("#")},
)
r.raise_for_status()
return r.json()
async def ensure_label(
self, owner: str, repo: str, name: str, color: str
) -> dict:
"""Return the repo label with this name, creating it if needed."""
key = f"{owner}/{repo}"
cache = self.__dict__.setdefault("_label_cache", {})
if key not in cache:
cache[key] = {l["name"]: l for l in await self.get_labels(owner, repo)}
if name not in cache[key]:
cache[key][name] = await self.create_label(owner, repo, name, color)
return cache[key][name]
async def add_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/labels",
json={"labels": [label_id]},
)
r.raise_for_status()
async def remove_issue_label(
self, owner: str, repo: str, number: int, label_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
)
r.raise_for_status()
async def delete_label(self, owner: str, repo: str, label_id: int) -> None:
"""Delete a label definition from the repo (all issues lose it)."""
r = await self.client.delete(f"/repos/{owner}/{repo}/labels/{label_id}")
r.raise_for_status()
self.forget_label_cache(owner, repo)
def forget_label_cache(self, owner: str, repo: str) -> None:
cache = self.__dict__.get("_label_cache")
if cache:
cache.pop(f"{owner}/{repo}", None)
# ── Issue search (global + assigned) ───────────────────
async def search_issues(
self,
q: str = "",
state: str = "open",
assigned_by: str | None = None,
) -> list[dict]:
"""Search issues across every repo visible to the token.
Uses Gitea's /repos/issues/search (type=issues excludes PRs).
Results carry their repository in issue["repository"].
"""
params: dict[str, Any] = {"state": state, "type": "issues"}
if q:
params["q"] = q
if assigned_by:
params["assigned_by"] = assigned_by
return await self._get_all_pages("/repos/issues/search", **params)
# ── Milestones ─────────────────────────────────────────
async def get_milestones(
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
key = f"milestones:{owner}/{repo}:{state}"
cached = self._cache_get(key)
if cached is not None:
return cached
milestones = await self._get_all_pages(
f"/repos/{owner}/{repo}/milestones", state=state
)
self._cache_put(key, milestones)
return milestones
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
) -> dict:
+815 -13
View File
@@ -16,6 +16,9 @@ FastAPI backend serving HTML + JSON API.
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import secrets
from contextlib import asynccontextmanager
@@ -24,18 +27,20 @@ from pathlib import Path
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
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,
@@ -45,6 +50,7 @@ from app.models import (
ping_db,
reconcile_board_state,
set_column_order,
set_kv,
set_notes,
)
@@ -83,6 +89,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()
@@ -99,7 +132,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()
@@ -119,7 +154,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
"""
async def dispatch(self, request: Request, call_next):
if request.url.path == "/api/health":
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
if request.url.path in ("/api/health", "/webhooks/gitea"):
return await call_next(request)
settings = get_settings()
if not settings.auth_enabled:
@@ -158,6 +195,37 @@ class AuthMiddleware(BaseHTTPMiddleware):
app.add_middleware(AuthMiddleware)
# ── Live updates (SSE hub) ─────────────────────────────────
class LiveHub:
"""Fan-out of Gitea webhook events to connected browsers (SSE)."""
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
async def broadcast(self, payload: dict) -> None:
for queue in list(self._subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass # slow client: it will resync on its next load
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
hub = LiveHub()
# ── Templates ──────────────────────────────────────────────
_tpl_env = Environment(
@@ -255,8 +323,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(
@@ -311,12 +386,13 @@ async def dashboard(request: Request):
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
active_window_days=get_settings().active_window_days,
metrics=get_metrics(window_days=30),
)
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str):
"""Kanban board for a specific repo."""
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
"""Kanban board for a specific repo (optionally one milestone)."""
full_name = f"{owner}/{repo}"
all_items = await gitea.get_repo_issues(owner, repo, state="all")
issues = [i for i in all_items if _is_issue(i)]
@@ -329,8 +405,47 @@ async def board(request: Request, owner: str, repo: str):
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
# Milestone view: filter the board to one milestone (sprint-style),
# with its deadline and completion progress.
milestones: list[dict] = []
selected_milestone = None
milestone_progress = None
display_issues = open_issues
try:
milestones = await gitea.get_milestones(owner, repo)
except Exception as exc: # milestones are decoration, never fatal
logger.debug("milestones failed for %s: %s", full_name, exc)
milestones = []
if milestone:
def _in_milestone(issue: dict) -> bool:
ms = issue.get("milestone") or {}
return bool(ms) and (
str(ms.get("id")) == milestone or ms.get("title") == milestone
)
display_issues = [i for i in open_issues if _in_milestone(i)]
selected_milestone = next(
(
m for m in milestones
if str(m.get("id")) == milestone or m.get("title") == milestone
),
None,
)
if selected_milestone is None and display_issues:
selected_milestone = display_issues[0].get("milestone")
scoped = [i for i in issues if _in_milestone(i)]
total = len(scoped)
closed_count = sum(1 for i in scoped if i.get("state") == "closed")
milestone_progress = {
"total": total,
"closed": closed_count,
"pct": round(100 * closed_count / total) if total else 0,
"due_on": (selected_milestone or {}).get("due_on"),
"title": (selected_milestone or {}).get("title", milestone),
}
board_state = get_board_state(full_name)
columns = _merge_issues_with_board(open_issues, board_state)
columns = _merge_issues_with_board(display_issues, board_state)
labels: dict[str, dict] = {}
for issue in open_issues:
@@ -356,9 +471,13 @@ async def board(request: Request, owner: str, repo: str):
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(full_name) or "",
repo_info=repo_info,
ages=_card_ages(full_name, open_issues, board_state),
ages=_card_ages(full_name, display_issues, board_state),
wip_limits=settings.wip_limits or {},
aging_days=settings.aging_days,
milestones=milestones,
selected_milestone=selected_milestone,
milestone_progress=milestone_progress,
milestone_param=milestone,
)
@@ -391,9 +510,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]
@@ -420,15 +538,343 @@ 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
@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(
full_name: str, number: int, target_column: str, event_type: str
) -> str:
"""Apply a close/reopen that happened in Gitea itself.
Idempotent by construction: when Kanbante's own API made the
change, the local row is already in the target state and this is
a no-op — so our own webhook echo never double-logs.
"""
board = get_board_state(full_name)
current = None
for column, cards in board.items():
if any(c["issue_number"] == number for c in cards):
current = column
break
if current is None or current == target_column:
return "noop"
if event_type == "reopened" and current != "done":
return "noop" # locally open already: nothing truthful to apply
move_issue(full_name, number, target_column)
if event_type == "closed":
log_event(full_name, number, "closed", to_column="done", source="webhook")
else:
log_event(
full_name, number, "reopened",
from_column="done", to_column=target_column, source="webhook",
)
return event_type
@app.post("/webhooks/gitea")
async def gitea_webhook(request: Request):
"""Receive Gitea webhooks: verify HMAC, apply external close/reopen,
invalidate the read cache and push a live event to browsers."""
settings = get_settings()
raw = await request.body()
if settings.webhook_secret:
signature = request.headers.get("x-gitea-signature", "")
expected = hmac.new(
settings.webhook_secret.encode(), raw, hashlib.sha256
).hexdigest()
if not signature or not hmac.compare_digest(signature, expected):
return JSONResponse(
{"ok": False, "error": "Invalid webhook signature"},
status_code=401,
)
try:
payload = json.loads(raw or b"{}")
except ValueError:
return JSONResponse(
{"ok": False, "error": "Invalid JSON payload"}, status_code=400
)
event = request.headers.get("x-gitea-event", "")
action = payload.get("action", "")
full_name = (payload.get("repository") or {}).get("full_name", "")
issue = payload.get("issue") or {}
number = issue.get("number")
handled = "ignored"
if event == "issues" and full_name and number:
if action == "closed":
handled = _apply_external_transition(full_name, number, "done", "closed")
elif action == "reopened":
handled = _apply_external_transition(
full_name, number, "backlog", "reopened"
)
gitea.invalidate_cache()
await hub.broadcast(
{"repo": full_name, "event": event, "action": action, "issue": number}
)
return {"ok": True, "handled": handled}
async def sse_event_stream(request: Request):
"""Yield SSE frames for one subscriber until it disconnects.
Split out of the route so it can be unit-tested directly:
test clients cannot reliably tear down an infinite HTTP stream.
"""
queue = hub.subscribe()
try:
yield "retry: 5000\n\n"
while True:
try:
payload = await asyncio.wait_for(queue.get(), timeout=25)
except asyncio.TimeoutError:
if await request.is_disconnected():
break
yield ": heartbeat\n\n"
continue
if await request.is_disconnected():
break
yield f"event: gitea\ndata: {json.dumps(payload)}\n\n"
finally:
hub.unsubscribe(queue)
@app.get("/api/stream")
async def live_stream(request: Request):
"""Server-Sent Events: browsers reload softly on Gitea activity."""
return StreamingResponse(
sse_event_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/activity", response_class=HTMLResponse)
async def activity(request: Request):
"""Activity feed: the latest entries of the event journal."""
@@ -517,6 +963,38 @@ async def api_move(
exc,
)
result["gitea_sync"] = "failed"
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
# the workflow state is visible in Gitea itself, not just here.
result["label_sync"] = "skipped"
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
)
issue = await gitea.get_issue(owner, repo, issue_number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await gitea.remove_issue_label(
owner, repo, issue_number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await gitea.ensure_label(
owner, repo, desired, COLUMN_COLORS[to_column]
)
await gitea.add_issue_label(owner, repo, issue_number, label["id"])
result["label_sync"] = "ok"
except Exception as exc:
logger.warning(
"Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc
)
result["label_sync"] = "failed"
return result
@@ -579,7 +1057,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": [
@@ -666,6 +1144,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,
+22
View File
@@ -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")
+13 -4
View File
@@ -8,10 +8,18 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 📰 Activité</h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 📰 <strong>Activité</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill active">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
@@ -48,5 +56,6 @@
<footer>
Kanbante v{{ version }} · journal d'événements
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+88 -32
View File
@@ -6,37 +6,63 @@
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/board.js" defer></script>
</head>
<body>
<header>
<h1>
<a href="/">📋 Kanbante</a>
<span class="sep">/</span>
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
</h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<a href="/activity" class="btn-sm">📰 Activité</a>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> {{ owner }} / <strong>{{ repo }}</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
</nav>
</header>
{% if milestones %}
<div class="milestone-bar">
<span class="milestone-label">🎯 Milestone</span>
<a href="/board/{{ owner }}/{{ repo }}" class="milestone-chip{% if not milestone_param %} active{% endif %}">Toutes</a>
{% for m in milestones %}
<a href="/board/{{ owner }}/{{ repo }}?milestone={{ m.title|urlencode }}"
class="milestone-chip{% if milestone_param == m.title or milestone_param == m.id|string %} active{% endif %}">{{ m.title }}</a>
{% endfor %}
</div>
{% endif %}
{% if milestone_progress %}
<div class="milestone-banner">
<div class="milestone-info">
<strong>🎯 {{ milestone_progress.title }}</strong>
{% if milestone_progress.due_on %}
<span class="milestone-due">📅 échéance {{ milestone_progress.due_on[:10] }}</span>
{% endif %}
<span class="milestone-count">{{ milestone_progress.closed }}/{{ milestone_progress.total }} issues terminées</span>
</div>
<div class="progress progress-lg"><span style="width: {{ milestone_progress.pct }}%"></span></div>
<span class="milestone-pct">{{ milestone_progress.pct }}%</span>
</div>
{% endif %}
<!-- Toolbar: text + label filters, and issue creation (Gitea-backed) -->
<div class="board-toolbar">
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes...">
<input type="text" id="card-filter" placeholder="🔍 Filtrer les cartes... ( / )">
<select id="label-filter">
<option value="">Toutes les étiquettes</option>
{% for label in labels %}
<option value="{{ label.name }}">{{ label.name }}</option>
{% endfor %}
</select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
@@ -56,6 +82,9 @@
{% if limit %}data-limit="{{ limit }}" title="Limite WIP : {{ limit }}"{% endif %}>{% if limit %}{{ columns[col]|length }}/{{ limit }}{% else %}{{ columns[col]|length }}{% endif %}</span>
</div>
<div class="column-cards" data-column="{{ col }}">
{% if not columns[col] %}
<p class="column-empty">Glissez une carte ici</p>
{% endif %}
{% for issue in columns[col] %}
<div class="kanban-card"
data-issue="{{ issue.number }}"
@@ -96,20 +125,51 @@
{% endfor %}
</div>
<!-- Notes panel -->
<aside class="notes-panel">
<h3>📝 Notes du projet</h3>
<form hx-post="/api/notes" hx-swap="none"
hx-on::after-request="if(event.detail.successful) showNotesSaved()">
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<textarea name="note" rows="10"
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</form>
<!-- Notes panel: collapsible + resizable + autosave -->
<aside class="notes-panel" id="notes-panel">
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
<div class="notes-head">
<h3>📝 Notes du projet</h3>
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
title="Replier / déplier le panneau">⤢</button>
</div>
<div class="notes-body-wrap">
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea. Enregistrement automatique.</p>
<form id="notes-form">
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
<textarea name="note" id="notes-text" rows="10"
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<div class="notes-foot">
<span id="notes-status" class="notes-status"></span>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</div>
</form>
</div>
</aside>
</main>
<!-- Label manager modal -->
<div id="label-modal" class="modal" hidden>
<div class="modal-box">
<div class="detail-head">
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
</div>
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
<div id="label-list" class="label-list"></div>
<form id="label-create-form" class="label-create-form">
<input type="text" id="new-label-name" maxlength="50" placeholder="Nom de la nouvelle étiquette" required>
<input type="color" id="new-label-color" value="#7c5cfc" title="Couleur">
<button type="submit" class="btn-save btn-inline">Créer</button>
</form>
<div class="label-swatches">
{% for c in ['e11d48','f59e0b','10b981','0ea5e9','7c5cfc','ec4899','64748b','84cc16'] %}
<button type="button" class="swatch" data-color="#{{ c }}" style="background: #{{ c }}" title="#{{ c }}"></button>
{% endfor %}
</div>
</div>
</div>
<!-- Card detail drawer: issue body, comments, comment form -->
<div id="detail-overlay" class="detail-overlay" hidden></div>
<aside id="card-detail" class="card-detail" hidden>
@@ -121,6 +181,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>
@@ -138,14 +202,6 @@
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
<script>
function showNotesSaved() {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = '✅ Notes sauvegardées';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
</script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!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>
</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>
+54 -9
View File
@@ -9,16 +9,33 @@
<script defer src="/static/vendor/alpine.min.js"></script>
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<nav>
<a href="/focus" class="btn-sm">🎯 Focus</a>
<a href="/activity" class="btn-sm">📰 Activité</a>
<span class="badge">{{ active_repos|length }} actifs</span>
<span class="badge secondary">{{ archived_repos|length }} archivés</span>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill active">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<section class="hero">
<div class="hero-text">
<h2>Bonjour 👋 Voici vos projets Gitea, en un coup d'œil.</h2>
<p>Glissez les cartes, suivez ce qui avance, et laissez Gitea rester la source de vérité.</p>
</div>
<div class="stat-tiles">
<div class="stat-tile"><span class="stat-num">{{ active_repos|length }}</span><span class="stat-label">projets actifs</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.closed }}</span><span class="stat-label">issues fermées · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{{ metrics.moved }}</span><span class="stat-label">déplacements · 30 j</span></div>
<div class="stat-tile"><span class="stat-num">{% if metrics.avg_cycle_days is not none %}{{ metrics.avg_cycle_days }} j{% else %}—{% endif %}</span><span class="stat-label">temps de cycle moyen</span></div>
</div>
</section>
<main x-data="{ tab: 'active', search: '' }">
<!-- Tabs: "actif" = mis à jour dans la fenêtre glissante;
"archivé" = le vrai drapeau Gitea (repo.archived). -->
@@ -37,7 +54,8 @@
<!-- Search: the repo name lives in a data-* attribute (HTML-escaped
by Jinja), never interpolated into a JS string literal. -->
<div class="search-bar">
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
<input type="text" id="project-search" placeholder="🔍 Filtrer les projets..." x-model="search" autocomplete="off">
<kbd class="kbd">/</kbd>
</div>
{% macro project_card(repo, archived=False) %}
@@ -53,8 +71,17 @@
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
{% if full in stats %}
{% set total = stats[full].values()|sum %}
{% set done_n = stats[full].get('done', 0) %}
{% if total > 0 %}
<div class="progress" title="{{ done_n }}/{{ total }} terminées">
<span style="width: {{ (100 * done_n / total)|round|int }}%"></span>
</div>
{% endif %}
{% endif %}
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues</span>
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','review','done'] %}
@@ -73,19 +100,37 @@
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}{{ project_card(repo) }}{% endfor %}
{% if not active_repos %}
<div class="empty-state">🌱 Aucun projet actif en ce moment. Les projets bougent dès qu'une issue ou un commit arrive dans Gitea.</div>
{% endif %}
</div>
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}{{ project_card(repo) }}{% endfor %}
{% if not inactive_repos %}
<div class="empty-state">✨ Rien d'endormi : tous vos projets ont eu de l'activité récemment.</div>
{% endif %}
</div>
<div x-show="tab === 'archived'" class="project-grid">
{% for repo in archived_repos %}{{ project_card(repo, archived=True) }}{% endfor %}
{% if not archived_repos %}
<div class="empty-state">🗄️ Aucun projet archivé. L'archivage se fait dans Gitea (Réglages du dépôt).</div>
{% endif %}
</div>
</main>
<footer>
Kanbante v{{ version }} · {{ active_repos|length + inactive_repos|length + archived_repos|length }} projets sur Gitea · actifs = mis à jour depuis {{ active_window_days }} jours
</footer>
<script src="/static/js/live.js" defer></script>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
e.preventDefault();
document.getElementById('project-search')?.focus();
}
});
</script>
</body>
</html>
+11 -2
View File
@@ -7,8 +7,17 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
</nav>
</header>
<main class="error-main">
<div class="error-box">
+13 -3
View File
@@ -8,9 +8,18 @@
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a> <span class="sep">/</span> 🎯 Focus</h1>
<nav>
<header class="app-header">
<div class="brand">
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
<span class="crumb"><span class="sep">/</span> 🎯 <strong>Focus</strong></span>
</div>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill active">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -58,5 +67,6 @@
<footer>
Kanbante v{{ version }} · vue Focus
</footer>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!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>
</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>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>
</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>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.3.0"
version = "0.5.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+645 -229
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -29,8 +29,10 @@ document.addEventListener('DOMContentLoaded', () => {
group: 'kanban',
animation: 200,
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;
const card = evt.item;
const formData = new FormData();
@@ -128,6 +130,169 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Keyboard: "/" jumps to the filter box.
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName || '')) {
e.preventDefault();
document.getElementById('card-filter')?.focus();
}
});
// ── Repo context (repoFullName is declared at the top) ──
const [repoOwner, repoName] = (repoFullName || '').split('/', 2);
// ── Notes panel: collapsible, resizable, autosave ──────
const notesPanel = document.getElementById('notes-panel');
if (notesPanel) {
const widthKey = 'kb.notes.w.' + repoFullName;
const collapsedKey = 'kb.notes.c.' + repoFullName;
const savedWidth = parseInt(localStorage.getItem(widthKey) || '0', 10);
if (savedWidth >= 200 && savedWidth <= 560) notesPanel.style.width = savedWidth + 'px';
const toggleBtn = document.getElementById('notes-toggle');
function applyCollapsed(collapsed) {
notesPanel.classList.toggle('collapsed', collapsed);
toggleBtn.textContent = collapsed ? '⤡' : '⤢';
toggleBtn.title = collapsed ? 'Déplier le panneau' : 'Replier le panneau';
}
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
toggleBtn?.addEventListener('click', () => {
const collapsed = !notesPanel.classList.contains('collapsed');
applyCollapsed(collapsed);
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
});
const handle = document.getElementById('notes-resize');
handle?.addEventListener('pointerdown', (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = notesPanel.getBoundingClientRect().width;
function onMove(ev) {
const w = Math.min(560, Math.max(200, startW + (startX - ev.clientX)));
notesPanel.style.width = w + 'px';
}
function onUp() {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
localStorage.setItem(widthKey, String(Math.round(notesPanel.getBoundingClientRect().width)));
}
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
});
const notesText = document.getElementById('notes-text');
const notesStatus = document.getElementById('notes-status');
let notesTimer = null;
async function saveNotes() {
const fd = new FormData();
fd.append('repo_full_name', repoFullName);
fd.append('note', notesText.value);
fd.append('csrf_token', csrfToken);
notesStatus.textContent = '💾 Enregistrement...';
try {
const r = await fetch('/api/notes', { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
notesStatus.textContent = '✓ Enregistré à ' + new Date().toLocaleTimeString('fr-CA');
} catch (err) {
notesStatus.textContent = '❌ Échec : ' + err.message;
}
}
notesText?.addEventListener('input', () => {
notesStatus.textContent = '✎ Modifié...';
clearTimeout(notesTimer);
notesTimer = setTimeout(saveNotes, 1200);
});
document.getElementById('notes-form')?.addEventListener('submit', (e) => {
e.preventDefault();
clearTimeout(notesTimer);
saveNotes();
});
}
// ── Label manager ──────────────────────────────────────
let repoLabelsCache = null;
async function fetchRepoLabels() {
if (repoLabelsCache) return repoLabelsCache;
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = data.labels;
return repoLabelsCache;
}
function labelChipEl(label) {
const span = document.createElement('span');
span.className = 'label-tag';
const color = /^[0-9a-fA-F]{6}$/.test(label.color || '') ? label.color : '888888';
span.style.background = '#' + color + '30';
span.style.color = '#' + color;
span.textContent = label.name;
return span;
}
const labelModal = document.getElementById('label-modal');
async function renderLabelList() {
const box = document.getElementById('label-list');
box.innerHTML = '';
try {
const labels = await fetchRepoLabels();
if (!labels.length) box.innerHTML = '<p class="detail-empty">Aucune étiquette dans ce dépôt.</p>';
labels.forEach(label => {
const row = document.createElement('div');
row.className = 'label-row';
row.appendChild(labelChipEl(label));
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-sm label-del';
del.textContent = '🗑️ Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer l\'étiquette « ' + label.name + ' » de tout le dépôt ?')) return;
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels/${label.id}`,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
repoLabelsCache = repoLabelsCache.filter(l => l.id !== label.id);
row.remove();
showToast('✅ Étiquette supprimée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
} catch (err) {
box.innerHTML = '<p class="detail-empty">Erreur : ' + err.message + '</p>';
}
}
document.getElementById('labels-btn')?.addEventListener('click', () => {
labelModal.hidden = false;
renderLabelList();
});
document.getElementById('label-modal-close')?.addEventListener('click', () => { labelModal.hidden = true; });
labelModal?.addEventListener('click', (e) => { if (e.target === labelModal) labelModal.hidden = true; });
document.querySelectorAll('.swatch').forEach(sw => sw.addEventListener('click', () => {
document.getElementById('new-label-color').value = sw.dataset.color;
}));
document.getElementById('label-create-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('new-label-name').value.trim();
const color = document.getElementById('new-label-color').value.replace('#', '');
if (!name) return;
const fd = new FormData();
fd.append('name', name);
fd.append('color', color);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`, { method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
if (repoLabelsCache) repoLabelsCache.push(data.label);
document.getElementById('new-label-name').value = '';
renderLabelList();
showToast('✅ Étiquette « ' + data.label.name + ' » créée');
} catch (err) { showToast('❌ ' + err.message, true); }
});
// ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel.
let downX = 0, downY = 0;
@@ -205,6 +370,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 || '#';
@@ -216,6 +382,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';
+44
View File
@@ -0,0 +1,44 @@
// Kanbante — live updates via Server-Sent Events.
// When Gitea reports activity (webhook -> /api/stream), reload the
// page softly if it is safe (visible tab, no drawer open, no text
// being typed, no drag in progress); otherwise show a pill the
// user can click when ready.
(function () {
if (!window.EventSource) return;
var pill = null;
var timer = null;
function showPill() {
if (pill) return;
pill = document.createElement('button');
pill.id = 'live-pill';
pill.type = 'button';
pill.textContent = '🔄 Nouvelle activité Gitea — actualiser';
pill.addEventListener('click', function () { window.location.reload(); });
document.body.appendChild(pill);
}
function safeToReload() {
if (document.visibilityState !== 'visible') return false;
if (window.__kanbanteDragging) return false;
var drawer = document.getElementById('card-detail');
if (drawer && !drawer.hidden) return false;
var active = document.activeElement;
if (active && (active.tagName === 'TEXTAREA' ||
(active.tagName === 'INPUT' && active.type === 'text'))) return false;
return true;
}
function scheduleReload() {
if (timer) return;
timer = setTimeout(function () {
timer = null;
if (safeToReload()) window.location.reload();
else showPill();
}, 1200);
}
var source = new EventSource('/api/stream');
source.addEventListener('gitea', scheduleReload);
})();
+87 -6
View File
@@ -47,17 +47,18 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
"title": title,
"body": f"Corps de l'issue {number} en **markdown**.",
"state": state,
"labels": [{"name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": None,
"assignees": [],
"milestone": None,
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
"assignee": {"login": "bruno", "avatar_url": ""} if number == 1 else None,
"assignees": [{"login": "bruno", "avatar_url": ""}] if number == 1 else [],
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
"due_date": None,
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-02T09:00:00Z",
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
# Faithful to real Gitea 1.27 payloads: the key is ALWAYS
# present — null for a plain issue, an object for a PR.
"pull_request": {"url": "http://gitea.test/pr"} if pr else None,
}
if pr:
issue["pull_request"] = {"url": "http://gitea.test/pr"}
return issue
@@ -79,6 +80,10 @@ class FakeGitea:
_issue(5, "Tâche focus"),
],
}
self.repo_labels: dict[str, dict[str, dict]] = {"Projets/demo": {}}
self.label_ops: list[tuple] = []
self._label_seq = 100
self.cache_invalidations = 0
self.state_changes: list[tuple] = []
self.created: list[tuple] = []
self.comments: dict[tuple, list[dict]] = {
@@ -105,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")
@@ -150,6 +186,51 @@ class FakeGitea:
self.posted_comments.append((owner, repo, number, body))
return comment
def invalidate_cache(self):
self.cache_invalidations += 1
async def get_labels(self, owner, repo):
return list(self.repo_labels.setdefault(f"{owner}/{repo}", {}).values())
async def create_label(self, owner, repo, name, color):
self._label_seq += 1
label = {"id": self._label_seq, "name": name, "color": color.lstrip("#")}
self.repo_labels.setdefault(f"{owner}/{repo}", {})[name] = label
self.label_ops.append(("create", f"{owner}/{repo}", name))
return label
async def ensure_label(self, owner, repo, name, color):
existing = self.repo_labels.setdefault(f"{owner}/{repo}", {})
if name in existing:
return existing[name]
return await self.create_label(owner, repo, name, color)
async def add_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
label = next(
l for l in self.repo_labels[f"{owner}/{repo}"].values()
if l["id"] == label_id
)
if label not in issue["labels"]:
issue["labels"].append(label)
self.label_ops.append(("add", number, label["name"]))
async def remove_issue_label(self, owner, repo, number, label_id):
issue = await self.get_issue(owner, repo, number)
removed = [l for l in issue["labels"] if l["id"] == label_id]
issue["labels"] = [l for l in issue["labels"] if l["id"] != label_id]
for label in removed:
self.label_ops.append(("remove", number, label["name"]))
async def get_milestones(self, owner, repo, state="open"):
return [
{
"id": 1, "title": "Sprint 1",
"due_on": "2026-10-20T00:00:00Z",
"open_issues": 1, "closed_issues": 1,
}
]
async def create_issue(self, owner, repo, title, body=""):
self._next_number += 1
issue = _issue(self._next_number, title)
+202
View File
@@ -0,0 +1,202 @@
"""Palier 2 (0.4.0): webhooks + SSE live, label sync, milestone boards."""
import asyncio
import hashlib
import hmac
import json
from app import models
from app.main import hub
from tests.conftest import csrf_from
def _move(client, number, to_column, from_column):
token = csrf_from(client)
return client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo", "issue_number": number,
"to_column": to_column, "from_column": from_column,
"csrf_token": token,
},
)
def _webhook(client, payload, event="issues", secret=None, raw=None):
body = raw if raw is not None else json.dumps(payload).encode()
headers = {"X-Gitea-Event": event, "Content-Type": "application/json"}
if secret is not None:
headers["X-Gitea-Signature"] = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return client.post("/webhooks/gitea", content=body, headers=headers)
# ── Hub SSE ────────────────────────────────────────────────
def test_hub_broadcast_reaches_subscribers():
async def scenario():
queue = hub.subscribe()
try:
await hub.broadcast({"repo": "a/b", "action": "closed"})
return await asyncio.wait_for(queue.get(), timeout=1)
finally:
hub.unsubscribe(queue)
payload = asyncio.run(scenario())
assert payload["action"] == "closed"
assert hub.subscriber_count == 0
def test_sse_event_stream_frames_and_cleanup():
# The generator is unit-tested directly: HTTP test clients cannot
# reliably tear down an infinite SSE stream (verified empirically).
from app.main import sse_event_stream
class FakeRequest:
async def is_disconnected(self):
return False
async def scenario():
gen = sse_event_stream(FakeRequest())
first = await gen.__anext__()
await hub.broadcast({"repo": "a/b", "action": "closed"})
second = await asyncio.wait_for(gen.__anext__(), timeout=5)
await gen.aclose()
return first, second
first, second = asyncio.run(scenario())
assert first == "retry: 5000\n\n"
assert second.startswith("event: gitea\ndata: ")
assert '"action": "closed"' in second
assert hub.subscriber_count == 0 # finally unsubscribed
# ── Webhooks ───────────────────────────────────────────────
def test_webhook_closed_applies_transition_and_journals(client, fake_gitea):
models.move_issue("Projets/demo", 1, "todo")
payload = {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}
r = _webhook(client, payload)
assert r.status_code == 200
assert r.json()["handled"] == "closed"
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["done"]] == [1]
events = models.get_events("Projets/demo")
assert events[0]["event_type"] == "closed"
assert events[0]["source"] == "webhook"
assert fake_gitea.cache_invalidations >= 1
# The echo of our own API moves arrives in the same state: no-op,
# no duplicate journal entry.
r = _webhook(client, payload)
assert r.json()["handled"] == "noop"
assert len(models.get_events("Projets/demo")) == 1
def test_webhook_reopened_moves_back_to_backlog(client):
models.move_issue("Projets/demo", 1, "done")
r = _webhook(client, {
"action": "reopened",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
})
assert r.json()["handled"] == "reopened"
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["backlog"]] == [1]
def test_webhook_signature_enforced_when_secret_set(client, monkeypatch):
monkeypatch.setenv("KANBANTE_WEBHOOK_SECRET", "s3cret")
payload = {
"action": "closed",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}
assert _webhook(client, payload).status_code == 401
assert _webhook(client, payload, secret="wrong").status_code == 401
assert _webhook(client, payload, secret="s3cret").status_code == 200
def test_webhook_invalid_json_is_400(client):
r = _webhook(client, None, raw=b"{not json")
assert r.status_code == 400
def test_webhook_ignores_unrelated_events(client):
r = _webhook(client, {
"action": "created",
"repository": {"full_name": "Projets/demo"},
"issue": {"number": 1},
}, event="issue_comment")
assert r.status_code == 200
assert r.json()["handled"] == "ignored"
# ── Synchro par étiquettes ─────────────────────────────────
def test_label_sync_follows_column_moves(client, fake_gitea):
r = _move(client, 1, "in_progress", "backlog")
assert r.json()["label_sync"] == "ok"
assert ("create", "Projets/demo", "kanban:in_progress") in fake_gitea.label_ops
assert ("add", 1, "kanban:in_progress") in fake_gitea.label_ops
r = _move(client, 1, "review", "in_progress")
assert r.json()["label_sync"] == "ok"
assert ("remove", 1, "kanban:in_progress") in fake_gitea.label_ops
assert ("add", 1, "kanban:review") in fake_gitea.label_ops
r = _move(client, 1, "done", "review")
assert r.json()["label_sync"] == "ok"
assert ("remove", 1, "kanban:review") in fake_gitea.label_ops
issue = fake_gitea.issues["Projets/demo"][0]
assert not any(l["name"].startswith("kanban:") for l in issue["labels"])
assert any(l["name"] == "bug" for l in issue["labels"]) # user's label kept
def test_label_sync_can_be_disabled(client, fake_gitea, monkeypatch):
monkeypatch.setenv("KANBANTE_SYNC_LABELS", "0")
r = _move(client, 1, "in_progress", "backlog")
assert r.json()["label_sync"] == "skipped"
assert fake_gitea.label_ops == []
# ── Boards par milestone ───────────────────────────────────
def test_board_filters_by_milestone_with_progress(client):
models.move_issue("Projets/demo", 3, "done")
r = client.get("/board/Projets/demo?milestone=Sprint%201")
assert r.status_code == 200
assert "Tâche un" in r.text # open, in Sprint 1
assert "Tâche focus" not in r.text # open, no milestone
assert "milestone-banner" in r.text
assert "1/2 issues terminées" in r.text
assert "50%" in r.text
assert "2026-10-20" in r.text # due_on from the milestone
def test_board_lists_milestone_chips(client):
r = client.get("/board/Projets/demo")
assert "milestone-chip" in r.text
assert "Sprint 1" in r.text
assert "milestone=Sprint" in r.text
def test_board_filter_by_milestone_id(client):
r = client.get("/board/Projets/demo?milestone=1")
assert "Tâche un" in r.text
assert "Tâche focus" not in r.text
# ── Refonte UI : marqueurs structurels ─────────────────────
def test_dashboard_uses_new_design_system(client):
r = client.get("/")
assert "app-header" in r.text
assert "nav-pill" in r.text
assert "stat-tile" in r.text
assert "live.js" in r.text
+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
+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