Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b8c506e0 | ||
|
|
943ecd6a6b | ||
|
|
af59417d07 | ||
|
|
a358c45d73 | ||
|
|
b7ddc10971 | ||
|
|
542ed70c1a | ||
|
|
52c41ead91 | ||
|
|
f40d4c817b | ||
|
|
71881514c9 | ||
|
|
7d2faf027d | ||
|
|
b99e87ccca | ||
|
|
6887d631ea | ||
|
|
a788db7b1f |
@@ -1,5 +1,104 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
- **Colonne QA** entre « En cours » et « Révision » (🧪, étiquette
|
||||
`kanban:qa`) : une étape de validation finale avant la révision.
|
||||
Le flux complet devient Backlog → À faire → En cours → QA →
|
||||
Révision → Terminé ; la vue Focus inclut les cartes en QA.
|
||||
- **Wallboard** (issue #14 de la feuille de route) : `GET /wall`,
|
||||
affichage plein écran pensé pour un écran d'atelier — rotation
|
||||
automatique (30 s) entre les projets actifs, compteurs des six
|
||||
colonnes, cartes en vol (En cours / QA / Révision / À faire) avec
|
||||
âge, fermées du jour, horloge ; rafraîchissement en direct via le
|
||||
flux SSE. `?repo=Proprietaire/depot` fige un seul projet. Lien
|
||||
« 🖥️ Wallboard » ajouté à la navigation de toutes les pages.
|
||||
- **Réconciliation par étiquettes** : si l'étiquette `kanban:*` d'une
|
||||
issue ouverte diverge de sa colonne enregistrée (modifiée dans
|
||||
Gitea ou par un autre outil), le board et la vue Focus adoptent la
|
||||
colonne de l'étiquette et journalisent le déplacement (source
|
||||
`gitea`). Règles : exactement une étiquette `kanban:*` fait foi ;
|
||||
zéro ou plusieurs = l'état local gagne ; inactif si
|
||||
`KANBANTE_SYNC_LABELS=0`. Déplacer une carte depuis Gitea devient
|
||||
donc possible.
|
||||
|
||||
10 tests ajoutés (98 au total).
|
||||
|
||||
## 0.5.2 — 2026-10-11
|
||||
|
||||
### Correctif
|
||||
- **L'indice « Glissez une carte ici » restait affiché après un
|
||||
dépôt dans une colonne vide** : il est rendu par le serveur pour
|
||||
les colonnes vides au chargement, et le glisser-déposer ne le
|
||||
retirait jamais du DOM. `refreshEmptyHints()` recalcule désormais
|
||||
l'indice d'après le contenu réel de chaque colonne après tout
|
||||
déplacement (et après un retour arrière en cas d'échec) : retiré
|
||||
dès qu'une carte est présente, rétabli quand la dernière carte
|
||||
part. SortableJS est en outre restreint aux seules cartes
|
||||
(`draggable: '.kanban-card'`), l'indice n'est plus un élément
|
||||
déplaçable parasite.
|
||||
|
||||
## 0.5.1 — 2026-10-11
|
||||
|
||||
### Correctif critique
|
||||
- **Les boards étaient vides contre un vrai Gitea** : l'API Gitea
|
||||
(1.27.3 vérifié) renvoie la clé `"pull_request": null` sur les
|
||||
issues ordinaires ; le filtre `_is_issue` testait la *présence* de
|
||||
la clé (`"pull_request" not in item`), donc chaque vraie issue
|
||||
était classée « pull request » et écartée. Board, vue Focus et
|
||||
briefing ne montraient aucune carte (le dashboard, qui lit les
|
||||
compteurs des dépôts, semblait normal). Corrigé : le filtre teste
|
||||
la *valeur* (`not item.get("pull_request")`).
|
||||
- Le faux Gitea des tests omettait la clé, ce qui masquait le bug :
|
||||
il reproduit désormais fidèlement le payload réel (clé toujours
|
||||
présente, `null` sur les issues), plus 4 tests de régression dédiés
|
||||
(88 tests au total). Smoke test réel : le board `bruno/flowdeck`
|
||||
passe de 0 à 6 cartes, dont l'issue de test « test d'ajout ! ».
|
||||
|
||||
## 0.5.0 — 2026-10-11
|
||||
|
||||
« Palier 3 » (points 7 à 9) + board enrichi.
|
||||
|
||||
### Ajouts
|
||||
- **Recherche globale** (`/search`) : recherche d'issues dans tous les
|
||||
projets via l'API Gitea, résultats groupés par projet avec la colonne
|
||||
Kanban de chaque issue. **Mes issues** (`/my`) : les issues ouvertes
|
||||
assignées à l'utilisateur du jeton, tous projets confondus.
|
||||
- **Briefing quotidien** : page `/briefing` (encours, activité 24 h,
|
||||
cartes vieillissantes, milestones proches), `GET /api/briefing`, et
|
||||
envoi **ntfy** manuel (`POST /api/briefing/send`) ou automatique à
|
||||
`KANBANTE_BRIEFING_TIME` via un planificateur interne (mémoire
|
||||
d'envoi en base, aucun doublon après redémarrage).
|
||||
- **Serveur MCP** (lecture seule) : JSON-RPC 2.0 sur `POST /mcp`
|
||||
(initialize, tools/list, tools/call, ping) avec 7 outils —
|
||||
`list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Aucune écriture,
|
||||
conformément au principe « lectures d'abord ». `GET /mcp` décrit le
|
||||
serveur.
|
||||
- **Gestion des étiquettes sur le board** : modale de gestion
|
||||
(lister / créer avec palette de couleurs / supprimer les étiquettes
|
||||
du dépôt dans Gitea) et, dans le panneau détail, ajout / retrait
|
||||
d'étiquettes sur l'issue en un clic, avec mise à jour immédiate de
|
||||
la carte. API : `GET|POST /api/repos/{o}/{r}/labels`,
|
||||
`DELETE /api/repos/{o}/{r}/labels/{id}`,
|
||||
`POST /api/issues/{o}/{r}/{n}/labels`.
|
||||
|
||||
### Améliorations
|
||||
- **Panneau Notes du projet** : repliable (bouton ⤢, état mémorisé par
|
||||
projet), **largeur ajustable** par glisser (mémorisée), et
|
||||
**sauvegarde automatique** après 1,2 s sans frappe avec indicateur
|
||||
d'état (le bouton manuel reste). Le formulaire quitte HTMX pour le
|
||||
même `board.js` que le reste du board.
|
||||
- Navigation unifiée enrichie sur toutes les pages : Mes issues,
|
||||
Briefing, Recherche.
|
||||
|
||||
### Tests
|
||||
- 19 tests ajoutés (84 au total) : recherche et mes issues, briefing
|
||||
(page, API, envoi ntfy, format), protocole MCP, API étiquettes,
|
||||
marqueurs du board. Un doublon de déclaration JS attrapé par
|
||||
`node --check` avant livraison.
|
||||
|
||||
## 0.4.0 — 2026-10-11
|
||||
|
||||
« Palier 2 » : temps réel et visible dans Gitea — plus une refonte
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## Features
|
||||
|
||||
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / inactifs / archivés — le vrai drapeau Gitea)
|
||||
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → Révision → Terminé, ordre des cartes persisté
|
||||
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → **QA** → Révision → Terminé, ordre des cartes persisté
|
||||
- 🎯 **Drag & Drop** — glisse les issues entre colonnes ; en cas d'échec, la carte est replacée
|
||||
- 🔗 **Gitea Sync bidirectionnelle** — « Terminé » ferme l'issue dans Gitea, la ressortir la rouvre ; l'état local est réconcilié à chaque chargement
|
||||
- ➕ **Création d'issues** depuis le board, filtres texte + étiquettes, cartes liées à Gitea
|
||||
@@ -14,7 +14,14 @@
|
||||
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
|
||||
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
|
||||
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
|
||||
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
|
||||
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
|
||||
- 🏷️ **Étiquettes gérables depuis le board** — créer / supprimer les étiquettes du dépôt, les appliquer aux issues depuis le panneau détail
|
||||
- 📝 **Notes de projet** — panneau repliable, largeur ajustable, sauvegarde automatique
|
||||
- 📝 **Notes** — bloc-notes par projet
|
||||
- 🔐 **Authentification** Basic Auth intégrée + protection CSRF (voir Sécurité)
|
||||
- 🌙 **Dark theme** — conçu pour les longues sessions
|
||||
@@ -101,6 +108,10 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_AGING_DAYS` | `7` | Jours dans la même colonne avant le badge « ⏳ » sur une carte (rouge au double) |
|
||||
| `KANBANTE_WEBHOOK_SECRET` | *(vide)* | Secret partagé du webhook Gitea ; si défini, la signature HMAC `X-Gitea-Signature` est exigée |
|
||||
| `KANBANTE_SYNC_LABELS` | `1` | `0` = ne plus refléter les colonnes intermédiaires en étiquettes `kanban:*` dans Gitea |
|
||||
| `KANBANTE_NTFY_URL` | *(vide)* | Base du serveur ntfy (ex. `https://ntfy.sh` ou auto-hébergé) pour le briefing |
|
||||
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
|
||||
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
|
||||
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
|
||||
|
||||
## Sécurité
|
||||
|
||||
@@ -142,7 +153,8 @@ kanbante/
|
||||
|---------|-------|-------------|
|
||||
| GET | `/` | Dashboard tous les projets |
|
||||
| GET | `/board/{owner}/{repo}` | Kanban board |
|
||||
| GET | `/focus` | En cours + En révision, tous projets actifs |
|
||||
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
|
||||
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
|
||||
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
|
||||
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
|
||||
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
|
||||
@@ -153,6 +165,35 @@ kanbante/
|
||||
| POST | `/webhooks/gitea` | Réception des webhooks Gitea (HMAC si secret défini) |
|
||||
| GET | `/api/stream` | Flux SSE des événements Gitea (rafraîchissement live) |
|
||||
| GET | `/board/{o}/{r}?milestone=` | Board filtré par milestone (titre ou id) |
|
||||
| GET | `/search?q=` | Recherche globale d'issues (tous projets, tous états) |
|
||||
| GET | `/my` | Issues ouvertes assignées à l'utilisateur du jeton |
|
||||
| GET | `/briefing` | Briefing quotidien (page lisible + statut ntfy) |
|
||||
| GET | `/api/briefing` | Briefing en JSON + texte formaté |
|
||||
| POST | `/api/briefing/send` | Envoyer le briefing sur ntfy maintenant |
|
||||
| GET/POST | `/mcp` | Serveur MCP lecture seule (JSON-RPC 2.0) |
|
||||
| GET/POST | `/api/repos/{o}/{r}/labels` | Lister / créer les étiquettes du dépôt |
|
||||
| DELETE | `/api/repos/{o}/{r}/labels/{id}` | Supprimer une étiquette du dépôt |
|
||||
| POST | `/api/issues/{o}/{r}/{n}/labels` | Ajouter / retirer une étiquette sur une issue |
|
||||
|
||||
## Brancher un client MCP
|
||||
|
||||
Kanbante expose un serveur MCP **en lecture seule** (aucune écriture :
|
||||
les lectures d'abord). Configuration type (transport HTTP) :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kanbante": {
|
||||
"url": "https://<votre-hôte-kanbante>/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Outils : `list_projects`, `get_board`, `get_issue`, `get_focus`,
|
||||
`search_issues`, `get_activity`, `get_metrics`. Si la Basic Auth
|
||||
Kanbante est active, le client doit envoyer l'en-tête
|
||||
`Authorization` correspondant.
|
||||
|
||||
## Activer le temps réel (webhook Gitea)
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Daily briefing: formatting and ntfy delivery.
|
||||
|
||||
The data itself is gathered in app.main (it needs the board helpers);
|
||||
this module only turns it into a readable text and pushes it to an
|
||||
ntfy topic. Kept dependency-light so it is unit-testable alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def ntfy_configured(settings: Settings) -> bool:
|
||||
return bool(settings.ntfy_url and settings.ntfy_topic)
|
||||
|
||||
|
||||
def format_briefing_text(data: dict) -> str:
|
||||
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||||
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
||||
activity = data["activity_24h"]
|
||||
lines.append(
|
||||
f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), "
|
||||
f"{activity['created']} créée(s), {activity['moved']} déplacement(s)."
|
||||
)
|
||||
if activity.get("avg_cycle_days") is not None:
|
||||
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
||||
|
||||
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||
for group in data["focus_groups"][:6]:
|
||||
lines.append(f" • {group['full_name']} : {group['count']}")
|
||||
for title in group["titles"][:3]:
|
||||
lines.append(f" – {title}")
|
||||
|
||||
if data["aging"]:
|
||||
lines.append("\n⏳ Cartes qui vieillissent :")
|
||||
for card in data["aging"][:8]:
|
||||
lines.append(
|
||||
f" • {card['repo']} #{card['number']} « {card['title']} » "
|
||||
f"— {card['days']} j en {card['column_label']}"
|
||||
)
|
||||
|
||||
if data["milestones_due"]:
|
||||
lines.append("\n📅 Milestones proches :")
|
||||
for ms in data["milestones_due"][:5]:
|
||||
lines.append(
|
||||
f" • {ms['repo']} « {ms['title']} » — échéance "
|
||||
f"{ms['due_on']} ({ms['open']} ouverte(s))"
|
||||
)
|
||||
|
||||
if data["focus_total"] == 0 and not data["aging"]:
|
||||
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_ntfy(settings: Settings, text: str) -> bool:
|
||||
"""POST the briefing to the configured ntfy topic. True on 2xx."""
|
||||
if not ntfy_configured(settings):
|
||||
return False
|
||||
url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
|
||||
headers = {
|
||||
# ntfy headers must stay ASCII; the body carries the accents.
|
||||
"Title": "Kanbante - briefing du jour",
|
||||
"Tags": "clipboard,chart_with_upwards_trend",
|
||||
}
|
||||
if settings.ntfy_token:
|
||||
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(url, content=text.encode("utf-8"), headers=headers)
|
||||
return response.status_code < 300
|
||||
+17
-1
@@ -28,6 +28,14 @@ Environment variables:
|
||||
X-Gitea-Signature HMAC is enforced.
|
||||
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
|
||||
columns to Gitea labels named kanban:<col>.
|
||||
KANBANTE_NTFY_URL Base URL of an ntfy server (e.g.
|
||||
https://ntfy.sh or self-hosted).
|
||||
KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing.
|
||||
KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy.
|
||||
KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which
|
||||
the daily briefing is sent to ntfy.
|
||||
Empty (default) = no automatic send,
|
||||
the /briefing page stays available.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -36,7 +44,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.4.0"
|
||||
APP_VERSION = "0.6.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -88,6 +96,10 @@ class Settings:
|
||||
aging_days: int = 7
|
||||
webhook_secret: str = ""
|
||||
sync_labels: bool = True
|
||||
ntfy_url: str = ""
|
||||
ntfy_topic: str = ""
|
||||
ntfy_token: str = ""
|
||||
briefing_time: str = ""
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -113,4 +125,8 @@ def get_settings() -> Settings:
|
||||
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
|
||||
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
|
||||
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
|
||||
ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""),
|
||||
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
||||
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
||||
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
||||
)
|
||||
|
||||
+35
-2
@@ -172,8 +172,10 @@ class GiteaClient:
|
||||
) -> list[dict]:
|
||||
"""Get issues for a repo (paginated, cached).
|
||||
|
||||
Note: Gitea's issues endpoint also returns pull requests; each
|
||||
PR carries a "pull_request" key and callers filter on it.
|
||||
Note: Gitea's issues endpoint also returns pull requests; a
|
||||
PR carries a non-null "pull_request" object, a plain issue
|
||||
carries "pull_request": null — callers filter on truthiness
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
@@ -269,6 +271,37 @@ class GiteaClient:
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def delete_label(self, owner: str, repo: str, label_id: int) -> None:
|
||||
"""Delete a label definition from the repo (all issues lose it)."""
|
||||
r = await self.client.delete(f"/repos/{owner}/{repo}/labels/{label_id}")
|
||||
r.raise_for_status()
|
||||
self.forget_label_cache(owner, repo)
|
||||
|
||||
def forget_label_cache(self, owner: str, repo: str) -> None:
|
||||
cache = self.__dict__.get("_label_cache")
|
||||
if cache:
|
||||
cache.pop(f"{owner}/{repo}", None)
|
||||
|
||||
# ── Issue search (global + assigned) ───────────────────
|
||||
|
||||
async def search_issues(
|
||||
self,
|
||||
q: str = "",
|
||||
state: str = "open",
|
||||
assigned_by: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search issues across every repo visible to the token.
|
||||
|
||||
Uses Gitea's /repos/issues/search (type=issues excludes PRs).
|
||||
Results carry their repository in issue["repository"].
|
||||
"""
|
||||
params: dict[str, Any] = {"state": state, "type": "issues"}
|
||||
if q:
|
||||
params["q"] = q
|
||||
if assigned_by:
|
||||
params["assigned_by"] = assigned_by
|
||||
return await self._get_all_pages("/repos/issues/search", **params)
|
||||
|
||||
# ── Milestones ─────────────────────────────────────────
|
||||
|
||||
async def get_milestones(
|
||||
|
||||
+726
-11
@@ -34,11 +34,13 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||
from app.models import (
|
||||
KANBAN_COLUMNS,
|
||||
column_entry_times,
|
||||
get_board_state,
|
||||
get_events,
|
||||
get_kv,
|
||||
get_metrics,
|
||||
get_notes,
|
||||
get_stats,
|
||||
@@ -48,6 +50,7 @@ from app.models import (
|
||||
ping_db,
|
||||
reconcile_board_state,
|
||||
set_column_order,
|
||||
set_kv,
|
||||
set_notes,
|
||||
)
|
||||
|
||||
@@ -61,6 +64,7 @@ COLUMN_LABELS = {
|
||||
"backlog": "📥 Backlog",
|
||||
"todo": "📋 À faire",
|
||||
"in_progress": "🚧 En cours",
|
||||
"qa": "🧪 QA",
|
||||
"review": "🔍 Révision",
|
||||
"done": "✅ Terminé",
|
||||
}
|
||||
@@ -69,11 +73,16 @@ COLUMN_COLORS = {
|
||||
"backlog": "#6b7280",
|
||||
"todo": "#3b82f6",
|
||||
"in_progress": "#f59e0b",
|
||||
"qa": "#06b6d4",
|
||||
"review": "#8b5cf6",
|
||||
"done": "#10b981",
|
||||
}
|
||||
|
||||
FOCUS_COLUMNS = ["in_progress", "review"]
|
||||
FOCUS_COLUMNS = ["in_progress", "qa", "review"]
|
||||
|
||||
# Columns mirrored to Gitea as kanban:<column> labels (backlog and
|
||||
# done carry none: done is the closed state itself).
|
||||
LABELLED_COLUMNS = ("todo", "in_progress", "qa", "review")
|
||||
|
||||
EVENT_ICONS = {
|
||||
"created": "➕",
|
||||
@@ -86,6 +95,33 @@ EVENT_ICONS = {
|
||||
|
||||
# ── App setup ──────────────────────────────────────────────
|
||||
|
||||
async def _briefing_scheduler() -> None:
|
||||
"""Send the daily briefing to ntfy at KANBANTE_BRIEFING_TIME.
|
||||
|
||||
Checks once a minute; the kv store remembers the last day sent so
|
||||
restarts never double-send.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
settings = get_settings()
|
||||
if settings.briefing_time and ntfy_configured(settings):
|
||||
now = datetime.now()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
if (
|
||||
now.strftime("%H:%M") >= settings.briefing_time
|
||||
and get_kv("briefing_last_sent") != today
|
||||
):
|
||||
data = await _briefing_data()
|
||||
if await send_ntfy(settings, format_briefing_text(data)):
|
||||
set_kv("briefing_last_sent", today)
|
||||
logger.info("Daily briefing sent to ntfy (%s)", today)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # never let the scheduler die silently
|
||||
logger.warning("Briefing scheduler error: %s", exc)
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
@@ -102,7 +138,9 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(
|
||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||
)
|
||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||
yield
|
||||
scheduler.cancel()
|
||||
await gitea.aclose()
|
||||
|
||||
|
||||
@@ -291,8 +329,15 @@ def classify_repos(
|
||||
|
||||
|
||||
def _is_issue(item: dict) -> bool:
|
||||
"""Gitea's issues endpoint also returns PRs; they carry this key."""
|
||||
return "pull_request" not in item
|
||||
"""True for issues, False for pull requests.
|
||||
|
||||
Gitea's issues endpoint also returns PRs. A PR carries a
|
||||
``pull_request`` *object*; a plain issue carries the same key
|
||||
with value ``null`` (verified against Gitea 1.27.3). Testing key
|
||||
presence therefore classifies every real issue as a PR and
|
||||
empties the board — test the value's truthiness instead.
|
||||
"""
|
||||
return not item.get("pull_request")
|
||||
|
||||
|
||||
def _merge_issues_with_board(
|
||||
@@ -314,6 +359,44 @@ def _merge_issues_with_board(
|
||||
return columns
|
||||
|
||||
|
||||
def _adopt_label_columns(
|
||||
full_name: str, open_issues: list[dict], board_state: dict
|
||||
) -> None:
|
||||
"""Adopt the column named by an issue's kanban:* label, if any.
|
||||
|
||||
Labels are the Gitea-visible mirror of columns (written on every
|
||||
drag). If the label changed outside Kanbante — edited in Gitea,
|
||||
or set by another tool — the board follows it and the move is
|
||||
journaled (source "gitea"). Rules: exactly one kanban:* label
|
||||
(zero or several = no opinion), target must be a labelled column,
|
||||
and label synchronisation must be enabled.
|
||||
"""
|
||||
if not get_settings().sync_labels:
|
||||
return
|
||||
placement: dict[int, str] = {}
|
||||
for col_name, cards in board_state.items():
|
||||
for card in cards:
|
||||
placement[card["issue_number"]] = col_name
|
||||
for issue in open_issues:
|
||||
names = [
|
||||
str(label.get("name", "")) for label in issue.get("labels") or []
|
||||
]
|
||||
kanban = [n for n in names if n.startswith("kanban:")]
|
||||
if len(kanban) != 1:
|
||||
continue
|
||||
target = kanban[0].split(":", 1)[1]
|
||||
if target not in LABELLED_COLUMNS:
|
||||
continue
|
||||
current = placement.get(issue["number"], "backlog")
|
||||
if current != target:
|
||||
move_issue(full_name, issue["number"], target)
|
||||
log_event(
|
||||
full_name, issue["number"], "moved",
|
||||
from_column=current, to_column=target, source="gitea",
|
||||
)
|
||||
placement[issue["number"]] = target
|
||||
|
||||
|
||||
def _split_full_name(repo_full_name: str) -> tuple[str, str] | None:
|
||||
owner, sep, repo = repo_full_name.partition("/")
|
||||
if not sep or not owner or not repo or "/" in repo:
|
||||
@@ -366,6 +449,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
|
||||
for number in changes["closed_numbers"]:
|
||||
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
||||
|
||||
# A kanban:* label changed outside Kanbante wins over local state.
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
|
||||
# Milestone view: filter the board to one milestone (sprint-style),
|
||||
# with its deadline and completion progress.
|
||||
milestones: list[dict] = []
|
||||
@@ -471,9 +557,8 @@ def _card_ages(
|
||||
return ages
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
async def _collect_focus_groups() -> list[dict]:
|
||||
"""In-progress / in-review cards across all active repos."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
active = active[:100]
|
||||
@@ -492,6 +577,7 @@ async def focus(request: Request):
|
||||
logger.warning("focus: repo fetch failed: %s", result)
|
||||
continue
|
||||
full_name, open_issues = result
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
columns = _merge_issues_with_board(open_issues, get_board_state(full_name))
|
||||
cards = [
|
||||
{"issue": issue, "column": col}
|
||||
@@ -500,15 +586,322 @@ async def focus(request: Request):
|
||||
]
|
||||
if cards:
|
||||
groups.append({"full_name": full_name, "cards": cards})
|
||||
return groups
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
return render(
|
||||
request,
|
||||
"focus.html",
|
||||
groups=groups,
|
||||
groups=await _collect_focus_groups(),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
def _group_issue_results(issues: list[dict]) -> list[dict]:
|
||||
"""Group search results by repo, annotated with the board column."""
|
||||
by_repo: dict[str, list[dict]] = {}
|
||||
for issue in issues:
|
||||
full_name = (issue.get("repository") or {}).get("full_name", "?")
|
||||
by_repo.setdefault(full_name, []).append(issue)
|
||||
groups = []
|
||||
for full_name in sorted(by_repo):
|
||||
state = get_board_state(full_name) if "/" in full_name else {}
|
||||
column_of = {
|
||||
card["issue_number"]: column
|
||||
for column, cards in state.items()
|
||||
for card in cards
|
||||
}
|
||||
rows = []
|
||||
for issue in by_repo[full_name]:
|
||||
if issue.get("state") == "open":
|
||||
column = column_of.get(issue["number"], "backlog")
|
||||
else:
|
||||
column = "done"
|
||||
rows.append({"issue": issue, "column": column})
|
||||
groups.append({"full_name": full_name, "rows": rows})
|
||||
return groups
|
||||
|
||||
|
||||
WALL_COLUMNS = ["in_progress", "qa", "review", "todo"]
|
||||
|
||||
|
||||
async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
"""Slides for the wallboard: one per active project.
|
||||
|
||||
Each slide carries the column counts, the cards in flight
|
||||
(in progress / QA / review / todo, in flow order) with their age
|
||||
in column, and how many cards were closed today (journal).
|
||||
"""
|
||||
if only_repo:
|
||||
if _split_full_name(only_repo) is None:
|
||||
return []
|
||||
known = await gitea.search_repos()
|
||||
match = [r for r in known if r.get("full_name") == only_repo]
|
||||
if not match:
|
||||
return []
|
||||
repos = match
|
||||
else:
|
||||
all_repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(all_repos)
|
||||
repos = active[:12]
|
||||
|
||||
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
closed_today: dict[str, int] = {}
|
||||
for event in get_events(limit=500):
|
||||
if event["event_type"] == "closed" and str(
|
||||
event["created_at"]
|
||||
).startswith(today):
|
||||
closed_today[event["repo_full_name"]] = (
|
||||
closed_today.get(event["repo_full_name"], 0) + 1
|
||||
)
|
||||
|
||||
slides = []
|
||||
for repo in repos:
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
try:
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("wall: issues failed for %s: %s", full_name, exc)
|
||||
continue
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
board_state = get_board_state(full_name)
|
||||
columns = _merge_issues_with_board(open_issues, board_state)
|
||||
ages = _card_ages(full_name, open_issues, board_state)
|
||||
aging_days = get_settings().aging_days
|
||||
cards = []
|
||||
for col in WALL_COLUMNS:
|
||||
for issue in columns[col]:
|
||||
age = ages.get(issue["number"]) or {}
|
||||
days = age.get("days")
|
||||
cards.append(
|
||||
{
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"column": col,
|
||||
"column_label": COLUMN_LABELS[col],
|
||||
"age_days": days,
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
)
|
||||
return slides
|
||||
|
||||
|
||||
@app.get("/wall", response_class=HTMLResponse)
|
||||
async def wall(request: Request, repo: str = ""):
|
||||
"""Wallboard: full-screen, auto-rotating, live project display."""
|
||||
slides = await _collect_wall_data(repo or None)
|
||||
return render(
|
||||
request,
|
||||
"wall.html",
|
||||
slides=slides,
|
||||
single=bool(repo),
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
columns=KANBAN_COLUMNS,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/search", response_class=HTMLResponse)
|
||||
async def search_page(request: Request, q: str = ""):
|
||||
"""Global issue search across every repo (Gitea search API)."""
|
||||
q = q.strip()
|
||||
groups: list[dict] = []
|
||||
total = 0
|
||||
if q:
|
||||
results = await gitea.search_issues(q=q, state="all")
|
||||
total = len(results)
|
||||
groups = _group_issue_results(results)
|
||||
return render(
|
||||
request,
|
||||
"search.html",
|
||||
q=q,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/my", response_class=HTMLResponse)
|
||||
async def my_issues(request: Request):
|
||||
"""Open issues assigned to the Gitea token's owner, all repos."""
|
||||
user = None
|
||||
groups: list[dict] = []
|
||||
total = 0
|
||||
try:
|
||||
user = await gitea.get_user()
|
||||
except Exception as exc:
|
||||
logger.debug("get_user failed for /my: %s", exc)
|
||||
if user and user.get("login"):
|
||||
results = await gitea.search_issues(
|
||||
state="open", assigned_by=user["login"]
|
||||
)
|
||||
total = len(results)
|
||||
groups = _group_issue_results(results)
|
||||
return render(
|
||||
request,
|
||||
"my.html",
|
||||
user=user,
|
||||
groups=groups,
|
||||
total=total,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
)
|
||||
|
||||
|
||||
# ── Briefing ───────────────────────────────────────────────
|
||||
|
||||
async def _briefing_data() -> dict:
|
||||
"""Everything the daily briefing needs, gathered in one dict."""
|
||||
settings = get_settings()
|
||||
focus_groups = await _collect_focus_groups()
|
||||
|
||||
repos = await gitea.search_repos()
|
||||
active, _i, _a = classify_repos(repos)
|
||||
soon = datetime.now(timezone.utc) + timedelta(days=7)
|
||||
aging: list[dict] = []
|
||||
milestones_due: list[dict] = []
|
||||
|
||||
async def _per_repo(repo: dict):
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
ages = _card_ages(full_name, open_issues, get_board_state(full_name))
|
||||
board = get_board_state(full_name)
|
||||
column_of = {
|
||||
card["issue_number"]: column
|
||||
for column, cards in board.items()
|
||||
for card in cards
|
||||
}
|
||||
repo_aging = [
|
||||
{
|
||||
"repo": full_name,
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"days": ages[issue["number"]]["days"],
|
||||
"column_label": COLUMN_LABELS[
|
||||
column_of.get(issue["number"], "backlog")
|
||||
],
|
||||
}
|
||||
for issue in open_issues
|
||||
if issue["number"] in ages
|
||||
and ages[issue["number"]]["days"] >= settings.aging_days
|
||||
]
|
||||
repo_milestones = []
|
||||
try:
|
||||
for ms in await gitea.get_milestones(owner, name):
|
||||
due = _parse_datetime(ms.get("due_on"))
|
||||
if due and due <= soon and (ms.get("open_issues") or 0) > 0:
|
||||
repo_milestones.append(
|
||||
{
|
||||
"repo": full_name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": str(ms["due_on"])[:10],
|
||||
"open": ms.get("open_issues") or 0,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("briefing milestones failed for %s: %s", full_name, exc)
|
||||
return repo_aging, repo_milestones
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_per_repo(r) for r in active[:100]), return_exceptions=True
|
||||
)
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.warning("briefing: repo fetch failed: %s", result)
|
||||
continue
|
||||
repo_aging, repo_milestones = result
|
||||
aging.extend(repo_aging)
|
||||
milestones_due.extend(repo_milestones)
|
||||
aging.sort(key=lambda c: -c["days"])
|
||||
|
||||
metrics_24h = get_metrics(window_days=1)
|
||||
metrics_30d = get_metrics(window_days=30)
|
||||
return {
|
||||
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
||||
"focus_groups": [
|
||||
{
|
||||
"full_name": g["full_name"],
|
||||
"count": len(g["cards"]),
|
||||
"titles": [c["issue"]["title"] for c in g["cards"]],
|
||||
}
|
||||
for g in focus_groups
|
||||
],
|
||||
"aging": aging[:10],
|
||||
"milestones_due": milestones_due[:5],
|
||||
"activity_24h": {
|
||||
**metrics_24h,
|
||||
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/briefing", response_class=HTMLResponse)
|
||||
async def briefing_page(request: Request):
|
||||
"""The daily briefing, readable on the web before it is pushed."""
|
||||
settings = get_settings()
|
||||
data = await _briefing_data()
|
||||
return render(
|
||||
request,
|
||||
"briefing.html",
|
||||
data=data,
|
||||
text=format_briefing_text(data),
|
||||
ntfy_configured=ntfy_configured(settings),
|
||||
briefing_time=settings.briefing_time,
|
||||
last_sent=get_kv("briefing_last_sent"),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/briefing")
|
||||
async def api_briefing():
|
||||
data = await _briefing_data()
|
||||
return {"ok": True, "briefing": data, "text": format_briefing_text(data)}
|
||||
|
||||
|
||||
@app.post("/api/briefing/send")
|
||||
async def api_briefing_send(request: Request, csrf_token: str = Form("")):
|
||||
"""Send the briefing to the configured ntfy topic, right now."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
settings = get_settings()
|
||||
if not ntfy_configured(settings):
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "ntfy is not configured "
|
||||
"(KANBANTE_NTFY_URL / KANBANTE_NTFY_TOPIC)",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
data = await _briefing_data()
|
||||
sent = await send_ntfy(settings, format_briefing_text(data))
|
||||
if not sent:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "ntfy server rejected or unreachable"},
|
||||
status_code=502,
|
||||
)
|
||||
set_kv("briefing_last_sent", datetime.now().strftime("%Y-%m-%d"))
|
||||
return {"ok": True, "sent": True}
|
||||
|
||||
|
||||
# ── Webhooks + live stream ─────────────────────────────────
|
||||
|
||||
def _apply_external_transition(
|
||||
@@ -715,9 +1108,7 @@ async def api_move(
|
||||
if settings.sync_labels and from_column != to_column:
|
||||
try:
|
||||
desired = (
|
||||
f"kanban:{to_column}"
|
||||
if to_column in ("todo", "in_progress", "review")
|
||||
else None
|
||||
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
|
||||
)
|
||||
issue = await gitea.get_issue(owner, repo, issue_number)
|
||||
current = [
|
||||
@@ -802,7 +1193,7 @@ def _issue_payload(issue: dict) -> dict:
|
||||
"updated_at": issue.get("updated_at", ""),
|
||||
"due_date": issue.get("due_date"),
|
||||
"labels": [
|
||||
{"name": l.get("name", ""), "color": l.get("color", "")}
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in issue.get("labels") or []
|
||||
],
|
||||
"assignees": [
|
||||
@@ -889,6 +1280,330 @@ async def api_metrics(repo: str = "", window: int = 30):
|
||||
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
||||
|
||||
|
||||
# ── Label management ───────────────────────────────────────
|
||||
|
||||
@app.get("/api/repos/{owner}/{repo}/labels")
|
||||
async def api_list_labels(owner: str, repo: str):
|
||||
"""All label definitions of a repo."""
|
||||
labels = await gitea.get_labels(owner, repo)
|
||||
return {
|
||||
"ok": True,
|
||||
"labels": [
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in labels
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/repos/{owner}/{repo}/labels")
|
||||
async def api_create_label(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
name: str = Form(...),
|
||||
color: str = Form("7c5cfc"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Create a label definition in the repo."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
name = name.strip()
|
||||
color = color.strip().lstrip("#")
|
||||
if not name or len(name) > 50:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Label name must be 1-50 chars"},
|
||||
status_code=400,
|
||||
)
|
||||
if len(color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in color):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Color must be a 6-digit hex value"},
|
||||
status_code=400,
|
||||
)
|
||||
label = await gitea.create_label(owner, repo, name, color)
|
||||
gitea.forget_label_cache(owner, repo)
|
||||
return JSONResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"label": {
|
||||
"id": label.get("id"),
|
||||
"name": label.get("name", name),
|
||||
"color": label.get("color", color),
|
||||
},
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/repos/{owner}/{repo}/labels/{label_id}")
|
||||
async def api_delete_label(
|
||||
request: Request, owner: str, repo: str, label_id: int
|
||||
):
|
||||
"""Delete a label definition (CSRF via the X-CSRF-Token header)."""
|
||||
if not _csrf_valid(request, ""):
|
||||
return _csrf_error()
|
||||
await gitea.delete_label(owner, repo, label_id)
|
||||
return {"ok": True, "deleted": label_id}
|
||||
|
||||
|
||||
@app.post("/api/issues/{owner}/{repo}/{number}/labels")
|
||||
async def api_toggle_issue_label(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
label_id: int = Form(...),
|
||||
action: str = Form(...),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Add or remove one label on one issue; returns the new label set."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
if action not in ("add", "remove"):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "action must be 'add' or 'remove'"},
|
||||
status_code=400,
|
||||
)
|
||||
if action == "add":
|
||||
await gitea.add_issue_label(owner, repo, number, label_id)
|
||||
else:
|
||||
await gitea.remove_issue_label(owner, repo, number, label_id)
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
return {
|
||||
"ok": True,
|
||||
"labels": [
|
||||
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in issue.get("labels") or []
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── MCP server (read-only tools over JSON-RPC) ─────────────
|
||||
|
||||
MCP_TOOLS = [
|
||||
{
|
||||
"name": "list_projects",
|
||||
"description": "List the projects (Gitea repos) Kanbante tracks, grouped by activity.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "get_board",
|
||||
"description": "Get the kanban board of one repo: columns with their cards.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
},
|
||||
"required": ["owner", "repo"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_issue",
|
||||
"description": "Get one issue with its body and comments.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"owner": {"type": "string"},
|
||||
"repo": {"type": "string"},
|
||||
"number": {"type": "integer"},
|
||||
},
|
||||
"required": ["owner", "repo", "number"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_focus",
|
||||
"description": "Cards in progress or in review across all active projects.",
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "search_issues",
|
||||
"description": "Search issues by text across all repos.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"},
|
||||
"state": {"type": "string", "enum": ["open", "closed", "all"]},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_activity",
|
||||
"description": "Latest entries of the board event journal.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"limit": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "get_metrics",
|
||||
"description": "Throughput and cycle-time metrics from the journal.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"repo": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _mcp_call_tool(name: str, args: dict) -> str:
|
||||
"""Execute one read-only MCP tool and return text content."""
|
||||
if name == "list_projects":
|
||||
repos = await gitea.search_repos()
|
||||
active, inactive, archived = classify_repos(repos)
|
||||
lines = [f"Active projects ({len(active)}):"]
|
||||
lines += [
|
||||
f"- {r['full_name']} ({r.get('open_issues_count') or 0} open issues)"
|
||||
for r in active
|
||||
]
|
||||
lines.append(
|
||||
f"Inactive: {len(inactive)} - Archived: {len(archived)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
if name == "get_board":
|
||||
owner, repo = args["owner"], args["repo"]
|
||||
items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
issues = [i for i in items if _is_issue(i)]
|
||||
open_issues = [i for i in issues if i.get("state") == "open"]
|
||||
columns = _merge_issues_with_board(open_issues, get_board_state(f"{owner}/{repo}"))
|
||||
return json.dumps(
|
||||
{
|
||||
column: [
|
||||
{
|
||||
"number": i["number"],
|
||||
"title": i["title"],
|
||||
"labels": [l.get("name") for l in i.get("labels") or []],
|
||||
}
|
||||
for i in cards
|
||||
]
|
||||
for column, cards in columns.items()
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "get_issue":
|
||||
detail = await api_issue_detail(args["owner"], args["repo"], int(args["number"]))
|
||||
return json.dumps(detail, ensure_ascii=False, default=str)
|
||||
if name == "get_focus":
|
||||
groups = await _collect_focus_groups()
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"repo": g["full_name"],
|
||||
"cards": [
|
||||
{
|
||||
"number": c["issue"]["number"],
|
||||
"title": c["issue"]["title"],
|
||||
"column": c["column"],
|
||||
}
|
||||
for c in g["cards"]
|
||||
],
|
||||
}
|
||||
for g in groups
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "search_issues":
|
||||
results = await gitea.search_issues(
|
||||
q=args["query"], state=args.get("state", "open")
|
||||
)
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"repo": (i.get("repository") or {}).get("full_name"),
|
||||
"number": i["number"],
|
||||
"title": i["title"],
|
||||
"state": i["state"],
|
||||
}
|
||||
for i in results
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if name == "get_activity":
|
||||
return json.dumps(
|
||||
get_events(limit=int(args.get("limit", 50))), ensure_ascii=False
|
||||
)
|
||||
if name == "get_metrics":
|
||||
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
@app.get("/mcp")
|
||||
async def mcp_info():
|
||||
"""Discoverability for the MCP endpoint (the protocol is POST)."""
|
||||
return {
|
||||
"name": "kanbante",
|
||||
"version": APP_VERSION,
|
||||
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
||||
"read_only": True,
|
||||
"tools": [t["name"] for t in MCP_TOOLS],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
|
||||
|
||||
Read-only by design (Bruno's rule: cross-platform reads first,
|
||||
writes go through approval flows elsewhere).
|
||||
"""
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None,
|
||||
"error": {"code": -32700, "message": "Parse error"}},
|
||||
status_code=400,
|
||||
)
|
||||
if isinstance(payload, list): # batch: not supported by this server
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": None,
|
||||
"error": {"code": -32600, "message": "Batch requests not supported"}},
|
||||
status_code=400,
|
||||
)
|
||||
method = payload.get("method", "")
|
||||
rpc_id = payload.get("id")
|
||||
if rpc_id is None: # notification: acknowledge silently
|
||||
return Response(status_code=202)
|
||||
params = payload.get("params") or {}
|
||||
|
||||
def _result(result):
|
||||
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
|
||||
|
||||
def _error(code, message):
|
||||
return {"jsonrpc": "2.0", "id": rpc_id,
|
||||
"error": {"code": code, "message": message}}
|
||||
|
||||
if method == "initialize":
|
||||
return _result(
|
||||
{
|
||||
"protocolVersion": params.get("protocolVersion", "2024-11-05"),
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||
}
|
||||
)
|
||||
if method == "ping":
|
||||
return _result({})
|
||||
if method == "tools/list":
|
||||
return _result({"tools": MCP_TOOLS})
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments") or {}
|
||||
try:
|
||||
text = await _mcp_call_tool(tool_name, arguments)
|
||||
except KeyError as exc:
|
||||
return _result(
|
||||
{"content": [{"type": "text", "text": f"Missing argument: {exc}"}],
|
||||
"isError": True}
|
||||
)
|
||||
except Exception as exc:
|
||||
return _result(
|
||||
{"content": [{"type": "text", "text": f"Tool error: {exc}"}],
|
||||
"isError": True}
|
||||
)
|
||||
return _result({"content": [{"type": "text", "text": text}], "isError": False})
|
||||
return _error(-32601, f"Method not found: {method}")
|
||||
|
||||
|
||||
@app.post("/api/notes")
|
||||
async def api_notes(
|
||||
request: Request,
|
||||
|
||||
+23
-1
@@ -16,7 +16,7 @@ import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
|
||||
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
|
||||
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "qa", "review", "done"]
|
||||
|
||||
|
||||
def db_path() -> str:
|
||||
@@ -79,6 +79,11 @@ def init_db() -> None:
|
||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||
ON board_state(repo_full_name, column_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kv_store (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||
ON board_events(repo_full_name, issue_number, created_at);
|
||||
"""
|
||||
@@ -234,6 +239,23 @@ def reconcile_board_state(
|
||||
return changed
|
||||
|
||||
|
||||
def get_kv(key: str) -> str | None:
|
||||
with get_db() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_kv(key: str, value: str) -> None:
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO kv_store (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
# ── Event journal ──────────────────────────────────────────
|
||||
|
||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
||||
+51
-23
@@ -6,7 +6,6 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>{{ repo }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script src="/static/vendor/sortable.min.js"></script>
|
||||
<script src="/static/vendor/marked.min.js"></script>
|
||||
<script src="/static/vendor/purify.min.js"></script>
|
||||
@@ -21,7 +20,10 @@
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
{% endif %}
|
||||
@@ -61,6 +63,7 @@
|
||||
<option value="{{ label.name }}">{{ label.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||
<form id="create-issue-form">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
||||
@@ -70,7 +73,7 @@
|
||||
|
||||
<main class="board-container">
|
||||
<div class="kanban-board">
|
||||
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
<div class="kanban-column"
|
||||
data-column="{{ col }}">
|
||||
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
|
||||
@@ -123,21 +126,51 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Notes panel -->
|
||||
<aside class="notes-panel">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea.</p>
|
||||
<form hx-post="/api/notes" hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful) showNotesSaved()">
|
||||
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<textarea name="note" rows="10"
|
||||
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||
</form>
|
||||
<!-- Notes panel: collapsible + resizable + autosave -->
|
||||
<aside class="notes-panel" id="notes-panel">
|
||||
<div class="notes-resize" id="notes-resize" title="Glisser pour ajuster la largeur"></div>
|
||||
<div class="notes-head">
|
||||
<h3>📝 Notes du projet</h3>
|
||||
<button type="button" id="notes-toggle" class="btn-sm notes-toggle"
|
||||
title="Replier / déplier le panneau">⤢</button>
|
||||
</div>
|
||||
<div class="notes-body-wrap">
|
||||
<p class="notes-hint">Contexte libre, propre à Kanbante — il ne part pas dans Gitea. Enregistrement automatique.</p>
|
||||
<form id="notes-form">
|
||||
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
|
||||
<textarea name="note" id="notes-text" rows="10"
|
||||
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
|
||||
<div class="notes-foot">
|
||||
<span id="notes-status" class="notes-status"></span>
|
||||
<button type="submit" class="btn-save">💾 Sauvegarder</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- Label manager modal -->
|
||||
<div id="label-modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<div class="detail-head">
|
||||
<h2>🏷️ Étiquettes de {{ full_name }}</h2>
|
||||
<button id="label-modal-close" class="btn-sm" type="button">✕</button>
|
||||
</div>
|
||||
<p class="notes-hint">Ces étiquettes vivent dans Gitea : les créer ou les supprimer ici les modifie pour tout le dépôt.</p>
|
||||
<div id="label-list" class="label-list"></div>
|
||||
<form id="label-create-form" class="label-create-form">
|
||||
<input type="text" id="new-label-name" maxlength="50" placeholder="Nom de la nouvelle étiquette" required>
|
||||
<input type="color" id="new-label-color" value="#7c5cfc" title="Couleur">
|
||||
<button type="submit" class="btn-save btn-inline">Créer</button>
|
||||
</form>
|
||||
<div class="label-swatches">
|
||||
{% for c in ['e11d48','f59e0b','10b981','0ea5e9','7c5cfc','ec4899','64748b','84cc16'] %}
|
||||
<button type="button" class="swatch" data-color="#{{ c }}" style="background: #{{ c }}" title="#{{ c }}"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card detail drawer: issue body, comments, comment form -->
|
||||
<div id="detail-overlay" class="detail-overlay" hidden></div>
|
||||
<aside id="card-detail" class="card-detail" hidden>
|
||||
@@ -149,6 +182,10 @@
|
||||
<h2 id="detail-title"></h2>
|
||||
<div id="detail-meta" class="detail-meta"></div>
|
||||
<div id="detail-labels" class="card-labels"></div>
|
||||
<div class="detail-labels-manage">
|
||||
<span class="notes-hint">Étiquettes de l'issue — cliquez pour ajouter / retirer :</span>
|
||||
<div id="detail-label-toggles" class="label-toggles"></div>
|
||||
</div>
|
||||
<div id="detail-body" class="detail-body"></div>
|
||||
<h3 class="detail-comments-title">💬 Commentaires</h3>
|
||||
<div id="detail-comments"></div>
|
||||
@@ -167,14 +204,5 @@
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
function showNotesSaved() {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = '✅ Notes sauvegardées';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Briefing — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 📊 <strong>Briefing</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<p class="focus-intro">Le briefing du <strong>{{ data.date }}</strong> — le même contenu que celui poussé sur ntfy.</p>
|
||||
|
||||
<div class="stat-tiles briefing-tiles">
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.focus_total }}</span><span class="stat-label">en cours / révision</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.closed }}</span><span class="stat-label">fermées · 24 h</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.activity_24h.moved }}</span><span class="stat-label">déplacements · 24 h</span></div>
|
||||
<div class="stat-tile"><span class="stat-num">{{ data.aging|length }}</span><span class="stat-label">cartes vieillissantes</span></div>
|
||||
</div>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📄 Contenu du briefing</h2>
|
||||
<pre class="briefing-text">{{ text }}</pre>
|
||||
</section>
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📲 Envoi ntfy</h2>
|
||||
{% if ntfy_configured %}
|
||||
<p class="notes-hint">ntfy est configuré. {% if briefing_time %}Envoi automatique chaque jour à <strong>{{ briefing_time }}</strong> (heure du serveur).{% else %}Aucune heure d'envoi automatique (<code>KANBANTE_BRIEFING_TIME</code> non défini).{% endif %}{% if last_sent %} Dernier envoi : {{ last_sent }}.{% endif %}</p>
|
||||
<form id="briefing-send-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn-save">📤 Envoyer maintenant via ntfy</button>
|
||||
</form>
|
||||
<p id="briefing-status" class="notes-hint"></p>
|
||||
{% else %}
|
||||
<p class="notes-hint">Pour recevoir ce briefing sur votre téléphone, définissez <code>KANBANTE_NTFY_URL</code> (ex. <code>https://ntfy.sh</code> ou votre ntfy auto-hébergé), <code>KANBANTE_NTFY_TOPIC</code> et, pour l'envoi automatique, <code>KANBANTE_BRIEFING_TIME=07:30</code>, puis redémarrez Kanbante.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · briefing quotidien
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('briefing-send-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('briefing-status');
|
||||
status.textContent = 'Envoi en cours...';
|
||||
const r = await fetch('/api/briefing/send', { method: 'POST', body: new FormData(e.target) });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,11 @@
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill active">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -81,7 +85,7 @@
|
||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||
{% if full in stats %}
|
||||
<div class="mini-progress">
|
||||
{% for col in ['backlog','todo','in_progress','review','done'] %}
|
||||
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
|
||||
{% set n = stats[full].get(col, 0) %}
|
||||
{% if n > 0 %}
|
||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="error-main">
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill active">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mes issues — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>👤</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 👤 <strong>Mes issues</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
{% if not user %}
|
||||
<div class="empty-state">🔑 Aucun utilisateur Gitea résolu : définissez <code>GITEA_TOKEN</code> pour voir les issues qui vous sont assignées.</div>
|
||||
{% else %}
|
||||
<p class="focus-intro">Issues ouvertes assignées à <strong>{{ user.login }}</strong>, tous projets confondus — {{ total }} au total.</p>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🎉 Aucune issue ne vous est assignée pour le moment.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<span class="issue-state">🟢</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · mes issues
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Recherche — Kanbante</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<a href="/" class="logo" aria-label="Kanbante — accueil"><span class="logo-mark">📋</span><span class="gradient-text">Kanbante</span></a>
|
||||
<span class="crumb"><span class="sep">/</span> 🔍 <strong>Recherche</strong></span>
|
||||
</div>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<form method="get" action="/search" class="search-hero">
|
||||
<input type="text" name="q" value="{{ q }}" autofocus
|
||||
placeholder="Rechercher dans toutes les issues de tous les projets...">
|
||||
<button type="submit" class="btn-save btn-inline">Rechercher</button>
|
||||
</form>
|
||||
|
||||
{% if q %}
|
||||
<p class="focus-intro">{{ total }} résultat(s) pour « {{ q }} » — recherche Gitea, tous projets confondus.</p>
|
||||
{% if not groups %}
|
||||
<div class="empty-state">🔍 Aucune issue ne correspond. Essayez un mot plus court ou vérifiez l'orthographe.</div>
|
||||
{% endif %}
|
||||
{% for group in groups %}
|
||||
<section class="focus-group">
|
||||
<h2><a href="/board/{{ group.full_name }}">{{ group.full_name }}</a>
|
||||
<span class="badge secondary">{{ group.rows|length }}</span></h2>
|
||||
<div class="issue-results">
|
||||
{% for row in group.rows %}
|
||||
{% set issue = row.issue %}
|
||||
<div class="issue-row">
|
||||
<span class="issue-state">{{ '✅' if issue.state != 'open' else '🟢' }}</span>
|
||||
<a class="issue-main" href="/board/{{ group.full_name }}">
|
||||
<span class="card-id">#{{ issue.number }}</span> {{ issue.title }}
|
||||
</a>
|
||||
<span class="col-chip" style="background: {{ column_colors[row.column] }}20; color: {{ column_colors[row.column] }}">{{ column_labels[row.column] }}</span>
|
||||
{% for label in issue.labels or [] %}
|
||||
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">{{ label.name }}</span>
|
||||
{% endfor %}
|
||||
<span class="activity-date">{{ (issue.updated_at or '')[:10] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">🔍 Tapez un mot-clé : la recherche couvre les titres et corps d'issues de <strong>tous</strong> vos projets Gitea, ouverts comme fermés.</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
Kanbante v{{ version }} · recherche globale
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kanbante — Wallboard</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body class="wall-body">
|
||||
{% if slides %}
|
||||
<div class="wall-stage" id="wall-stage">
|
||||
{% for slide in slides %}
|
||||
<section class="wall-slide{% if loop.first %} active{% endif %}">
|
||||
<header class="wall-head">
|
||||
<div>
|
||||
<div class="wall-kicker">📂 Projet</div>
|
||||
<h1 class="wall-project">{{ slide.full_name }}</h1>
|
||||
</div>
|
||||
<div class="wall-clock">
|
||||
<div class="wall-time" data-clock>--:--</div>
|
||||
<div class="wall-date" data-date></div>
|
||||
{% if not single and slides|length > 1 %}
|
||||
<div class="wall-rotation">{{ loop.index }} / {{ slides|length }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wall-counts">
|
||||
{% for col in columns %}
|
||||
<div class="wall-count" style="--col: {{ column_colors[col] }}">
|
||||
<span class="wall-count-n">{{ slide.counts[col] }}</span>
|
||||
<span class="wall-count-l">{{ column_labels[col] }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if slide.cards %}
|
||||
<ul class="wall-cards">
|
||||
{% for card in slide.cards %}
|
||||
<li class="wall-card">
|
||||
<span class="wall-card-col" style="--col: {{ column_colors[card.column] }}">{{ card.column_label }}</span>
|
||||
<span class="wall-card-title">#{{ card.number }} · {{ card.title }}</span>
|
||||
{% if card.age_days is not none and card.age_days >= 1 %}
|
||||
<span class="wall-card-age{% if card.aging %} hot{% endif %}">⏳ {{ card.age_days }} j</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="wall-idle">🌿 Rien en vol pour le moment — tout est au Backlog ou terminé.</p>
|
||||
{% endif %}
|
||||
|
||||
<footer class="wall-foot">
|
||||
<span>✅ Fermées aujourd'hui : <strong>{{ slide.closed_today }}</strong></span>
|
||||
<span>📋 Ouvertes : <strong>{{ slide.open_total }}</strong></span>
|
||||
<span class="wall-live">● EN DIRECT</span>
|
||||
</footer>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="wall-empty">
|
||||
<h1>🖥️ Wallboard</h1>
|
||||
<p>Aucun projet actif à afficher{% if single %} pour ce dépôt{% endif %}.</p>
|
||||
<p class="wall-empty-hint">Astuce : <code>/wall?repo=Proprietaire/depot</code> fige un seul projet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<script src="/static/js/wall.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -719,6 +719,154 @@ footer {
|
||||
.activity-text { flex: 1; }
|
||||
.card-date { color: var(--text-3); }
|
||||
|
||||
/* ── Search / my issues / briefing ──────────── */
|
||||
.search-hero { display: flex; gap: 10px; margin-bottom: 18px; }
|
||||
.search-hero input {
|
||||
flex: 1;
|
||||
padding: 13px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
}
|
||||
.search-hero input:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.issue-results { display: flex; flex-direction: column; gap: 7px; }
|
||||
.issue-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.issue-row:hover { border-color: var(--border-strong); }
|
||||
.issue-main { color: var(--text); flex: 1; min-width: 220px; }
|
||||
.issue-main:hover { color: #a5c4fd; text-decoration: none; }
|
||||
.col-chip {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.briefing-tiles { margin-bottom: 18px; }
|
||||
.briefing-card {
|
||||
background: linear-gradient(180deg, var(--surface-2), var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.briefing-card h2 { font-size: 1.02rem; margin-bottom: 12px; }
|
||||
.briefing-text {
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
.briefing-card code, .empty-state code {
|
||||
background: var(--surface-3);
|
||||
padding: 1px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
/* ── Notes panel v2 ─────────────────────────── */
|
||||
.notes-panel { position: relative; transition: width 0.18s ease; }
|
||||
.notes-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.notes-head h3 { margin-bottom: 0; }
|
||||
.notes-toggle { padding: 4px 10px; }
|
||||
.notes-panel.collapsed { width: 54px !important; padding: 14px 10px; }
|
||||
.notes-panel.collapsed .notes-body-wrap { display: none; }
|
||||
.notes-panel.collapsed .notes-head h3 { display: none; }
|
||||
.notes-panel.collapsed .notes-head { justify-content: center; }
|
||||
.notes-resize {
|
||||
position: absolute;
|
||||
left: -3px; top: 0;
|
||||
width: 7px; height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
.notes-resize:hover { background: rgba(124, 92, 252, 0.35); }
|
||||
.notes-foot { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-top: 10px; }
|
||||
.notes-foot .btn-save { margin-top: 0; }
|
||||
.notes-status { font-size: 0.76rem; color: var(--text-3); }
|
||||
|
||||
/* ── Label manager + toggles ────────────────── */
|
||||
.modal {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(4, 6, 10, 0.6);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
}
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
width: min(560px, 94vw);
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 22px;
|
||||
animation: drawer-in 0.22s cubic-bezier(0.2, 0.8, 0.3, 1);
|
||||
}
|
||||
.modal-box h2 { font-size: 1.1rem; }
|
||||
.label-list { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; }
|
||||
.label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.label-del { font-size: 0.78rem; padding: 5px 10px; }
|
||||
.label-create-form { display: flex; gap: 8px; align-items: center; }
|
||||
.label-create-form input[type="text"] {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.label-create-form input[type="text"]:focus { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.label-create-form input[type="color"] {
|
||||
width: 44px; height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
padding: 3px;
|
||||
}
|
||||
.label-swatches { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.swatch {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.25);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.swatch:hover { transform: scale(1.15); }
|
||||
.detail-labels-manage { margin: 4px 0 10px; }
|
||||
.label-toggles { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.label-toggle { cursor: pointer; opacity: 0.45; transition: opacity 0.15s, transform 0.1s; }
|
||||
.label-toggle:hover { opacity: 0.85; }
|
||||
.label-toggle.on { opacity: 1; box-shadow: 0 0 0 1.5px currentColor inset; }
|
||||
|
||||
/* ── Responsive ─────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.hero { flex-direction: column; align-items: flex-start; }
|
||||
@@ -745,3 +893,77 @@ footer {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Wallboard (0.6.0) : affichage plein écran, lisible à 3 mètres ── */
|
||||
.wall-body {
|
||||
background: #0b1220;
|
||||
color: #e5ecf5;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.wall-stage { position: relative; min-height: 100vh; }
|
||||
.wall-slide {
|
||||
display: none;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 4vh 4vw;
|
||||
flex-direction: column;
|
||||
gap: 3vh;
|
||||
}
|
||||
.wall-slide.active { display: flex; animation: wall-in 0.45s ease; }
|
||||
@keyframes wall-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
.wall-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start; gap: 24px;
|
||||
}
|
||||
.wall-kicker { font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase; color: #8fa3bf; }
|
||||
.wall-project { font-size: clamp(2.2rem, 5.2vw, 4.2rem); margin: 0.4rem 0 0; line-height: 1.05; }
|
||||
.wall-clock { text-align: right; }
|
||||
.wall-time { font-size: clamp(2rem, 4.5vw, 3.6rem); font-weight: 800; line-height: 1; }
|
||||
.wall-date { color: #8fa3bf; font-size: 1.15rem; margin-top: 0.35rem; text-transform: capitalize; }
|
||||
.wall-rotation { color: #5b6b84; margin-top: 0.4rem; font-size: 1rem; }
|
||||
.wall-counts {
|
||||
display: grid; grid-template-columns: repeat(6, 1fr); gap: 14px;
|
||||
}
|
||||
.wall-count {
|
||||
border: 1.5px solid var(--col);
|
||||
border-top: 6px solid var(--col);
|
||||
border-radius: 14px;
|
||||
padding: 14px 10px 12px;
|
||||
text-align: center;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.wall-count-n { display: block; font-size: clamp(1.9rem, 3.6vw, 3rem); font-weight: 800; }
|
||||
.wall-count-l { display: block; color: #aebdd2; font-size: clamp(0.78rem, 1.25vw, 1.02rem); margin-top: 2px; }
|
||||
.wall-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||
.wall-card {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
border-radius: 14px;
|
||||
padding: 16px 20px;
|
||||
font-size: clamp(1.15rem, 2.1vw, 1.7rem);
|
||||
}
|
||||
.wall-card-col {
|
||||
border: 1.5px solid var(--col); color: var(--col);
|
||||
border-radius: 999px; padding: 4px 14px;
|
||||
font-size: 0.62em; font-weight: 700; white-space: nowrap;
|
||||
}
|
||||
.wall-card-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.wall-card-age { color: #fbbf24; font-weight: 700; white-space: nowrap; }
|
||||
.wall-card-age.hot { color: #f87171; }
|
||||
.wall-idle { font-size: 1.5rem; color: #8fa3bf; }
|
||||
.wall-foot {
|
||||
margin-top: auto;
|
||||
display: flex; gap: 36px; flex-wrap: wrap;
|
||||
font-size: clamp(1rem, 1.7vw, 1.35rem); color: #aebdd2;
|
||||
}
|
||||
.wall-live { color: #34d399; font-weight: 700; letter-spacing: 0.08em; }
|
||||
.wall-empty {
|
||||
min-height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px; text-align: center;
|
||||
color: #aebdd2; padding: 24px;
|
||||
}
|
||||
.wall-empty h1 { color: #e5ecf5; font-size: 2.6rem; margin: 0; }
|
||||
.wall-empty code { background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 6px; }
|
||||
@media (max-width: 900px) {
|
||||
.wall-counts { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@@ -28,12 +28,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
new Sortable(col, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
draggable: '.kanban-card',
|
||||
ghostClass: 'dragging',
|
||||
onStart: function () { window.__kanbanteDragging = true; },
|
||||
dragClass: 'dragging',
|
||||
onEnd: function (evt) {
|
||||
window.__kanbanteDragging = false;
|
||||
if (evt.from === evt.to && evt.oldIndex === evt.newIndex) return;
|
||||
refreshEmptyHints();
|
||||
const card = evt.item;
|
||||
const formData = new FormData();
|
||||
formData.append('repo_full_name', card.dataset.repo);
|
||||
@@ -69,6 +71,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
.catch(err => {
|
||||
console.error('Move failed:', err);
|
||||
revert(evt);
|
||||
refreshEmptyHints();
|
||||
showToast('❌ Échec du déplacement — carte replacée', true);
|
||||
});
|
||||
}
|
||||
@@ -115,6 +118,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The "Glissez une carte ici" hint is rendered by the server only
|
||||
// for columns empty at page load. After a drag, the DOM is the
|
||||
// truth: remove the hint from columns that hold cards, restore it
|
||||
// on columns that became empty. (Bug reported 2026-10-11: the
|
||||
// hint stayed forever after dropping a card into an empty column.)
|
||||
function refreshEmptyHints() {
|
||||
document.querySelectorAll('.column-cards').forEach(list => {
|
||||
const hint = list.querySelector('.column-empty');
|
||||
const hasCards = !!list.querySelector('.kanban-card');
|
||||
if (hasCards && hint) hint.remove();
|
||||
if (!hasCards && !hint) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'column-empty';
|
||||
p.textContent = 'Glissez une carte ici';
|
||||
list.appendChild(p);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
document.querySelectorAll('.kanban-column').forEach(col => {
|
||||
const count = col.querySelectorAll('.kanban-card').length;
|
||||
@@ -138,6 +160,161 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Repo context (repoFullName is declared at the top) ──
|
||||
const [repoOwner, repoName] = (repoFullName || '').split('/', 2);
|
||||
|
||||
// ── Notes panel: collapsible, resizable, autosave ──────
|
||||
const notesPanel = document.getElementById('notes-panel');
|
||||
if (notesPanel) {
|
||||
const widthKey = 'kb.notes.w.' + repoFullName;
|
||||
const collapsedKey = 'kb.notes.c.' + repoFullName;
|
||||
const savedWidth = parseInt(localStorage.getItem(widthKey) || '0', 10);
|
||||
if (savedWidth >= 200 && savedWidth <= 560) notesPanel.style.width = savedWidth + 'px';
|
||||
const toggleBtn = document.getElementById('notes-toggle');
|
||||
function applyCollapsed(collapsed) {
|
||||
notesPanel.classList.toggle('collapsed', collapsed);
|
||||
toggleBtn.textContent = collapsed ? '⤡' : '⤢';
|
||||
toggleBtn.title = collapsed ? 'Déplier le panneau' : 'Replier le panneau';
|
||||
}
|
||||
applyCollapsed(localStorage.getItem(collapsedKey) === '1');
|
||||
toggleBtn?.addEventListener('click', () => {
|
||||
const collapsed = !notesPanel.classList.contains('collapsed');
|
||||
applyCollapsed(collapsed);
|
||||
localStorage.setItem(collapsedKey, collapsed ? '1' : '0');
|
||||
});
|
||||
|
||||
const handle = document.getElementById('notes-resize');
|
||||
handle?.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = notesPanel.getBoundingClientRect().width;
|
||||
function onMove(ev) {
|
||||
const w = Math.min(560, Math.max(200, startW + (startX - ev.clientX)));
|
||||
notesPanel.style.width = w + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
localStorage.setItem(widthKey, String(Math.round(notesPanel.getBoundingClientRect().width)));
|
||||
}
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
});
|
||||
|
||||
const notesText = document.getElementById('notes-text');
|
||||
const notesStatus = document.getElementById('notes-status');
|
||||
let notesTimer = null;
|
||||
async function saveNotes() {
|
||||
const fd = new FormData();
|
||||
fd.append('repo_full_name', repoFullName);
|
||||
fd.append('note', notesText.value);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
notesStatus.textContent = '💾 Enregistrement...';
|
||||
try {
|
||||
const r = await fetch('/api/notes', { method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
notesStatus.textContent = '✓ Enregistré à ' + new Date().toLocaleTimeString('fr-CA');
|
||||
} catch (err) {
|
||||
notesStatus.textContent = '❌ Échec : ' + err.message;
|
||||
}
|
||||
}
|
||||
notesText?.addEventListener('input', () => {
|
||||
notesStatus.textContent = '✎ Modifié...';
|
||||
clearTimeout(notesTimer);
|
||||
notesTimer = setTimeout(saveNotes, 1200);
|
||||
});
|
||||
document.getElementById('notes-form')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(notesTimer);
|
||||
saveNotes();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Label manager ──────────────────────────────────────
|
||||
let repoLabelsCache = null;
|
||||
async function fetchRepoLabels() {
|
||||
if (repoLabelsCache) return repoLabelsCache;
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
repoLabelsCache = data.labels;
|
||||
return repoLabelsCache;
|
||||
}
|
||||
|
||||
function labelChipEl(label) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'label-tag';
|
||||
const color = /^[0-9a-fA-F]{6}$/.test(label.color || '') ? label.color : '888888';
|
||||
span.style.background = '#' + color + '30';
|
||||
span.style.color = '#' + color;
|
||||
span.textContent = label.name;
|
||||
return span;
|
||||
}
|
||||
|
||||
const labelModal = document.getElementById('label-modal');
|
||||
async function renderLabelList() {
|
||||
const box = document.getElementById('label-list');
|
||||
box.innerHTML = '';
|
||||
try {
|
||||
const labels = await fetchRepoLabels();
|
||||
if (!labels.length) box.innerHTML = '<p class="detail-empty">Aucune étiquette dans ce dépôt.</p>';
|
||||
labels.forEach(label => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'label-row';
|
||||
row.appendChild(labelChipEl(label));
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn-sm label-del';
|
||||
del.textContent = '🗑️ Supprimer';
|
||||
del.addEventListener('click', async () => {
|
||||
if (!confirm('Supprimer l\'étiquette « ' + label.name + ' » de tout le dépôt ?')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels/${label.id}`,
|
||||
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrfToken } });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
repoLabelsCache = repoLabelsCache.filter(l => l.id !== label.id);
|
||||
row.remove();
|
||||
showToast('✅ Étiquette supprimée');
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
row.appendChild(del);
|
||||
box.appendChild(row);
|
||||
});
|
||||
} catch (err) {
|
||||
box.innerHTML = '<p class="detail-empty">Erreur : ' + err.message + '</p>';
|
||||
}
|
||||
}
|
||||
document.getElementById('labels-btn')?.addEventListener('click', () => {
|
||||
labelModal.hidden = false;
|
||||
renderLabelList();
|
||||
});
|
||||
document.getElementById('label-modal-close')?.addEventListener('click', () => { labelModal.hidden = true; });
|
||||
labelModal?.addEventListener('click', (e) => { if (e.target === labelModal) labelModal.hidden = true; });
|
||||
document.querySelectorAll('.swatch').forEach(sw => sw.addEventListener('click', () => {
|
||||
document.getElementById('new-label-color').value = sw.dataset.color;
|
||||
}));
|
||||
document.getElementById('label-create-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('new-label-name').value.trim();
|
||||
const color = document.getElementById('new-label-color').value.replace('#', '');
|
||||
if (!name) return;
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('color', color);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const r = await fetch(`/api/repos/${repoOwner}/${repoName}/labels`, { method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
if (repoLabelsCache) repoLabelsCache.push(data.label);
|
||||
document.getElementById('new-label-name').value = '';
|
||||
renderLabelList();
|
||||
showToast('✅ Étiquette « ' + data.label.name + ' » créée');
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
|
||||
// ── Card detail drawer ─────────────────────────────────
|
||||
// A click opens the panel; a drag must not. Track pointer travel.
|
||||
let downX = 0, downY = 0;
|
||||
@@ -215,6 +392,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
span.textContent = l.name;
|
||||
labelsBox.appendChild(span);
|
||||
});
|
||||
renderLabelToggles(issue);
|
||||
document.getElementById('detail-body').innerHTML =
|
||||
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
|
||||
document.getElementById('detail-gitea-link').href = issue.html_url || '#';
|
||||
@@ -226,6 +404,60 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderLabelToggles(issue) {
|
||||
const box = document.getElementById('detail-label-toggles');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
let all = [];
|
||||
try { all = await fetchRepoLabels(); } catch { return; }
|
||||
const activeIds = new Set((issue.labels || []).map(l => l.id));
|
||||
all.forEach(label => {
|
||||
const chip = labelChipEl(label);
|
||||
chip.classList.add('label-toggle');
|
||||
if (activeIds.has(label.id)) chip.classList.add('on');
|
||||
chip.title = activeIds.has(label.id) ? 'Retirer de l\'issue' : 'Ajouter à l\'issue';
|
||||
chip.addEventListener('click', async () => {
|
||||
const action = activeIds.has(label.id) ? 'remove' : 'add';
|
||||
const fd = new FormData();
|
||||
fd.append('label_id', label.id);
|
||||
fd.append('action', action);
|
||||
fd.append('csrf_token', csrfToken);
|
||||
try {
|
||||
const [owner, repo] = currentIssue.repo.split('/', 2);
|
||||
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/labels`,
|
||||
{ method: 'POST', body: fd });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
issue.labels = data.labels;
|
||||
// Refresh the chips in the drawer and on the board card.
|
||||
const labelsBox = document.getElementById('detail-labels');
|
||||
labelsBox.innerHTML = '';
|
||||
data.labels.forEach(l => labelsBox.appendChild(labelChipEl(l)));
|
||||
updateCardLabels(currentIssue.number, data.labels);
|
||||
renderLabelToggles(issue);
|
||||
} catch (err) { showToast('❌ ' + err.message, true); }
|
||||
});
|
||||
box.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCardLabels(number, labels) {
|
||||
const card = document.querySelector(`.kanban-card[data-issue="${number}"]`);
|
||||
if (!card) return;
|
||||
card.dataset.labels = labels.map(l => (l.name || '').toLowerCase()).join(',');
|
||||
let box = card.querySelector('.card-labels');
|
||||
if (!box && labels.length) {
|
||||
box = document.createElement('div');
|
||||
box.className = 'card-labels';
|
||||
card.querySelector('.card-title')?.after(box);
|
||||
}
|
||||
if (box) {
|
||||
box.innerHTML = '';
|
||||
labels.forEach(l => box.appendChild(labelChipEl(l)));
|
||||
box.style.display = labels.length ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function commentNode(c) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'comment';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Kanbante — Wallboard: slide rotation, clock, live refresh.
|
||||
*
|
||||
* The wall is a display, not a workspace: no interaction to protect,
|
||||
* so a realtime event simply reloads the page (plus a slow safety
|
||||
* reload in case the stream drops silently).
|
||||
*/
|
||||
(function () {
|
||||
var slides = Array.from(document.querySelectorAll('.wall-slide'));
|
||||
var ROTATE_MS = 30000;
|
||||
var current = 0;
|
||||
|
||||
if (slides.length > 1) {
|
||||
setInterval(function () {
|
||||
slides[current].classList.remove('active');
|
||||
current = (current + 1) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
}, ROTATE_MS);
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
var now = new Date();
|
||||
var time = now.toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' });
|
||||
var date = now.toLocaleDateString('fr-CA', {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
});
|
||||
document.querySelectorAll('[data-clock]').forEach(function (el) { el.textContent = time; });
|
||||
document.querySelectorAll('[data-date]').forEach(function (el) { el.textContent = date; });
|
||||
}
|
||||
tickClock();
|
||||
setInterval(tickClock, 15000);
|
||||
|
||||
// Live: any board activity refreshes the wall.
|
||||
try {
|
||||
var source = new EventSource('/api/stream');
|
||||
source.onmessage = function () { location.reload(); };
|
||||
} catch (err) {
|
||||
console.warn('Wallboard live stream unavailable:', err);
|
||||
}
|
||||
// Safety net.
|
||||
setTimeout(function () { location.reload(); }, 5 * 60 * 1000);
|
||||
})();
|
||||
+36
-4
@@ -48,16 +48,17 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
||||
"body": f"Corps de l'issue {number} en **markdown**.",
|
||||
"state": state,
|
||||
"labels": [{"id": 900, "name": "bug", "color": "ff0000"}] if number == 1 else [],
|
||||
"assignee": None,
|
||||
"assignees": [],
|
||||
"assignee": {"login": "bruno", "avatar_url": ""} if number == 1 else None,
|
||||
"assignees": [{"login": "bruno", "avatar_url": ""}] if number == 1 else [],
|
||||
"milestone": {"id": 1, "title": "Sprint 1"} if number in (1, 3) else None,
|
||||
"due_date": None,
|
||||
"created_at": "2026-09-01T09:00:00Z",
|
||||
"updated_at": "2026-09-02T09:00:00Z",
|
||||
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
|
||||
# Faithful to real Gitea 1.27 payloads: the key is ALWAYS
|
||||
# present — null for a plain issue, an object for a PR.
|
||||
"pull_request": {"url": "http://gitea.test/pr"} if pr else None,
|
||||
}
|
||||
if pr:
|
||||
issue["pull_request"] = {"url": "http://gitea.test/pr"}
|
||||
return issue
|
||||
|
||||
|
||||
@@ -109,6 +110,37 @@ class FakeGitea:
|
||||
async def ping(self) -> bool:
|
||||
return self.ping_result
|
||||
|
||||
async def get_user(self):
|
||||
return {"id": 1, "login": "bruno"}
|
||||
|
||||
async def search_issues(self, q="", state="open", assigned_by=None):
|
||||
results = []
|
||||
for full_name, issues in self.issues.items():
|
||||
for issue in issues:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
if state != "all" and issue["state"] != state:
|
||||
continue
|
||||
if q and q.lower() not in issue["title"].lower():
|
||||
continue
|
||||
if assigned_by:
|
||||
logins = [a["login"] for a in issue.get("assignees") or []]
|
||||
if issue.get("assignee"):
|
||||
logins.append(issue["assignee"]["login"])
|
||||
if assigned_by not in logins:
|
||||
continue
|
||||
results.append({**issue, "repository": {"full_name": full_name}})
|
||||
return results
|
||||
|
||||
async def delete_label(self, owner, repo, label_id):
|
||||
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
for name in [n for n, l in labels.items() if l["id"] == label_id]:
|
||||
del labels[name]
|
||||
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
||||
|
||||
def forget_label_cache(self, owner, repo):
|
||||
pass
|
||||
|
||||
async def search_repos(self):
|
||||
if self.raise_on_search:
|
||||
raise httpx.HTTPError("simulated Gitea outage")
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Palier 3 (0.5.0): search + my issues, briefing/ntfy, MCP, labels UI API."""
|
||||
|
||||
import json
|
||||
|
||||
from app import models
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
|
||||
# ── Point 7 : recherche globale + mes issues ───────────────
|
||||
|
||||
def test_search_page_without_query(client):
|
||||
r = client.get("/search")
|
||||
assert r.status_code == 200
|
||||
assert "search-hero" in r.text
|
||||
|
||||
|
||||
def test_search_finds_issues_across_repos(client):
|
||||
r = client.get("/search?q=focus")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" in r.text
|
||||
assert "Tâche un" not in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_search_includes_closed_with_done_chip(client):
|
||||
r = client.get("/search?q=ancienne")
|
||||
assert "Ancienne tâche" in r.text
|
||||
assert "Terminé" in r.text # column chip for closed issues
|
||||
|
||||
|
||||
def test_my_issues_lists_only_assigned_open(client):
|
||||
r = client.get("/my")
|
||||
assert r.status_code == 200
|
||||
assert "bruno" in r.text
|
||||
assert "Tâche un" in r.text # assigned to bruno, open
|
||||
assert "Tâche focus" not in r.text # not assigned
|
||||
|
||||
|
||||
# ── Point 8 : briefing ─────────────────────────────────────
|
||||
|
||||
def test_kv_store_roundtrip():
|
||||
models.set_kv("k1", "v1")
|
||||
assert models.get_kv("k1") == "v1"
|
||||
models.set_kv("k1", "v2")
|
||||
assert models.get_kv("k1") == "v2"
|
||||
assert models.get_kv("absent") is None
|
||||
|
||||
|
||||
def test_briefing_page_and_api(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/briefing")
|
||||
assert r.status_code == 200
|
||||
assert "briefing-text" in r.text
|
||||
r = client.get("/api/briefing")
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["briefing"]["focus_total"] >= 1
|
||||
assert "Kanbante" in data["text"]
|
||||
|
||||
|
||||
def test_briefing_send_requires_ntfy_config(client):
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/briefing/send", data={"csrf_token": token})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_briefing_send_posts_to_ntfy(client, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_NTFY_URL", "https://ntfy.example")
|
||||
monkeypatch.setenv("KANBANTE_NTFY_TOPIC", "kanbante-bruno")
|
||||
captured = {}
|
||||
|
||||
async def fake_send(settings, text):
|
||||
captured["topic"] = settings.ntfy_topic
|
||||
captured["text"] = text
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("app.main.send_ntfy", fake_send)
|
||||
token = csrf_from(client)
|
||||
r = client.post("/api/briefing/send", data={"csrf_token": token})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["sent"] is True
|
||||
assert captured["topic"] == "kanbante-bruno"
|
||||
assert "briefing" in captured["text"]
|
||||
assert models.get_kv("briefing_last_sent") is not None
|
||||
|
||||
|
||||
def test_briefing_format_is_plain_text():
|
||||
from app.briefing import format_briefing_text
|
||||
|
||||
text = format_briefing_text({
|
||||
"date": "11/10/2026",
|
||||
"activity_24h": {"closed": 2, "created": 1, "moved": 5, "avg_cycle_days": 3.5},
|
||||
"focus_total": 1,
|
||||
"focus_groups": [{"full_name": "a/b", "count": 1, "titles": ["X"]}],
|
||||
"aging": [{"repo": "a/b", "number": 4, "title": "Vieille",
|
||||
"days": 12, "column_label": "À faire"}],
|
||||
"milestones_due": [{"repo": "a/b", "title": "S1",
|
||||
"due_on": "2026-10-15", "open": 3}],
|
||||
})
|
||||
assert "2 fermée(s)" in text
|
||||
assert "Vieille" in text and "12 j" in text
|
||||
assert "S1" in text
|
||||
|
||||
|
||||
# ── Point 9 : MCP ──────────────────────────────────────────
|
||||
|
||||
def _rpc(client, method, params=None, rpc_id=1):
|
||||
payload = {"jsonrpc": "2.0", "id": rpc_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
return client.post("/mcp", json=payload)
|
||||
|
||||
|
||||
def test_mcp_initialize_and_tools_list(client):
|
||||
r = _rpc(client, "initialize", {"protocolVersion": "2024-11-05"})
|
||||
result = r.json()["result"]
|
||||
assert result["serverInfo"]["name"] == "kanbante"
|
||||
assert result["protocolVersion"] == "2024-11-05"
|
||||
r = _rpc(client, "tools/list")
|
||||
names = [t["name"] for t in r.json()["result"]["tools"]]
|
||||
assert names == [
|
||||
"list_projects", "get_board", "get_issue", "get_focus",
|
||||
"search_issues", "get_activity", "get_metrics",
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_call_get_board_and_search(client):
|
||||
r = _rpc(client, "tools/call",
|
||||
{"name": "get_board", "arguments": {"owner": "Projets", "repo": "demo"}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is False
|
||||
board = json.loads(result["content"][0]["text"])
|
||||
assert any(c["title"] == "Tâche un" for c in board["backlog"])
|
||||
|
||||
r = _rpc(client, "tools/call",
|
||||
{"name": "search_issues", "arguments": {"query": "focus"}})
|
||||
found = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert [f["title"] for f in found] == ["Tâche focus"]
|
||||
|
||||
|
||||
def test_mcp_call_metrics_and_activity(client):
|
||||
models.log_event("Projets/demo", 1, "moved", to_column="todo")
|
||||
r = _rpc(client, "tools/call", {"name": "get_activity", "arguments": {}})
|
||||
events = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert events[0]["event_type"] == "moved"
|
||||
r = _rpc(client, "tools/call", {"name": "get_metrics", "arguments": {}})
|
||||
metrics = json.loads(r.json()["result"]["content"][0]["text"])
|
||||
assert metrics["moved"] >= 1
|
||||
|
||||
|
||||
def test_mcp_unknown_method_and_tool(client):
|
||||
r = _rpc(client, "resources/list")
|
||||
assert r.json()["error"]["code"] == -32601
|
||||
r = _rpc(client, "tools/call", {"name": "delete_everything", "arguments": {}})
|
||||
result = r.json()["result"]
|
||||
assert result["isError"] is True # read-only server: no such tool
|
||||
|
||||
|
||||
def test_mcp_notification_gets_202(client):
|
||||
r = client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
assert r.status_code == 202
|
||||
|
||||
|
||||
def test_mcp_info_endpoint(client):
|
||||
r = client.get("/mcp")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["read_only"] is True
|
||||
|
||||
|
||||
# ── Board : gestion des étiquettes ────────────────────────
|
||||
|
||||
def test_labels_api_create_list_delete(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "urgent", "color": "e11d48", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
label = r.json()["label"]
|
||||
assert label["name"] == "urgent"
|
||||
|
||||
r = client.get("/api/repos/Projets/demo/labels")
|
||||
assert [l["name"] for l in r.json()["labels"]] == ["urgent"]
|
||||
|
||||
r = client.request(
|
||||
"DELETE", f"/api/repos/Projets/demo/labels/{label['id']}",
|
||||
headers={"X-CSRF-Token": token},
|
||||
)
|
||||
assert r.json()["ok"] is True
|
||||
r = client.get("/api/repos/Projets/demo/labels")
|
||||
assert r.json()["labels"] == []
|
||||
|
||||
|
||||
def test_labels_api_validation_and_csrf(client):
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "x", "color": "ffffff"},
|
||||
)
|
||||
assert r.status_code == 403 # no CSRF token
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "x", "color": "pashex", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_issue_label_toggle(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/repos/Projets/demo/labels",
|
||||
data={"name": "revue", "color": "0ea5e9", "csrf_token": token},
|
||||
)
|
||||
label_id = r.json()["label"]["id"]
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "add", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert [l["name"] for l in r.json()["labels"]] == ["revue"]
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "remove", "csrf_token": token},
|
||||
)
|
||||
assert r.json()["labels"] == []
|
||||
r = client.post(
|
||||
"/api/issues/Projets/demo/5/labels",
|
||||
data={"label_id": label_id, "action": "explode", "csrf_token": token},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_board_has_label_manager_and_new_notes_panel(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert 'id="labels-btn"' in r.text
|
||||
assert 'id="label-modal"' in r.text
|
||||
assert 'id="notes-panel"' in r.text
|
||||
assert 'id="notes-resize"' in r.text
|
||||
assert 'id="notes-toggle"' in r.text
|
||||
assert 'id="notes-status"' in r.text
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Palier 4 (0.6.0): QA column, label reconciliation, wallboard."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.main as main
|
||||
from app import models
|
||||
from tests.conftest import _issue, csrf_from
|
||||
|
||||
|
||||
def _column_block(html: str, column: str) -> str:
|
||||
blocks = html.split('<div class="kanban-column"')
|
||||
for block in blocks[1:]:
|
||||
if f'data-column="{column}"' in block.split(">", 1)[0]:
|
||||
return block
|
||||
raise AssertionError(f"column {column} not found")
|
||||
|
||||
|
||||
def _labelled(number: int, title: str, label_names: list[str]) -> dict:
|
||||
issue = _issue(number, title)
|
||||
issue["labels"] = [
|
||||
{"id": 900 + i, "name": name, "color": "8b5cf6"}
|
||||
for i, name in enumerate(label_names)
|
||||
]
|
||||
return issue
|
||||
|
||||
|
||||
# ── QA column ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_board_has_six_columns_including_qa(client):
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "🧪 QA" in r.text
|
||||
positions = [
|
||||
r.text.index(f'data-column="{col}"')
|
||||
for col in ("backlog", "todo", "in_progress", "qa", "review", "done")
|
||||
]
|
||||
assert positions == sorted(positions) # QA between in_progress and review
|
||||
|
||||
|
||||
def test_move_to_qa_syncs_kanban_qa_label(client, fake_gitea):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/api/move",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "issue_number": 1,
|
||||
"to_column": "qa", "from_column": "backlog",
|
||||
"to_order": "1", "from_order": "5", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["label_sync"] == "ok"
|
||||
assert ("add", 1, "kanban:qa") in fake_gitea.label_ops
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert 1 in [c["issue_number"] for c in state["qa"]]
|
||||
|
||||
|
||||
def test_qa_cards_appear_in_focus(client, fake_gitea):
|
||||
models.move_issue("Projets/demo", 5, "qa")
|
||||
r = client.get("/focus")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche focus" in r.text
|
||||
assert "🧪 QA" in r.text
|
||||
|
||||
|
||||
# ── Label reconciliation (adoption) ───────────────────────────────
|
||||
|
||||
|
||||
def test_board_adopts_diverging_kanban_label(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Déplacée via Gitea", ["kanban:review"])
|
||||
)
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Déplacée via Gitea" in _column_block(r.text, "review")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert 9 in [c["issue_number"] for c in state["review"]]
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo") if e["issue_number"] == 9
|
||||
]
|
||||
assert any(
|
||||
e["event_type"] == "moved" and e["to_column"] == "review"
|
||||
and e["source"] == "gitea"
|
||||
for e in events
|
||||
)
|
||||
|
||||
|
||||
def test_board_ignores_conflicting_kanban_labels(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Étiquettes en conflit", ["kanban:todo", "kanban:review"])
|
||||
)
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Étiquettes en conflit" in _column_block(r.text, "backlog")
|
||||
state = models.get_board_state("Projets/demo")
|
||||
elsewhere = [
|
||||
c["issue_number"]
|
||||
for col, cards in state.items() if col != "backlog"
|
||||
for c in cards
|
||||
]
|
||||
assert 9 not in elsewhere
|
||||
|
||||
|
||||
def test_adoption_is_idempotent(client, fake_gitea):
|
||||
fake_gitea.issues["Projets/demo"].append(
|
||||
_labelled(9, "Stable", ["kanban:qa"])
|
||||
)
|
||||
client.get("/board/Projets/demo")
|
||||
client.get("/board/Projets/demo")
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo")
|
||||
if e["issue_number"] == 9 and e["event_type"] == "moved"
|
||||
]
|
||||
assert len(events) == 1 # adopted once, then label and state agree
|
||||
|
||||
|
||||
# ── Wallboard ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wall_renders_active_projects(client):
|
||||
models.move_issue("Projets/demo", 5, "in_progress")
|
||||
r = client.get("/wall")
|
||||
assert r.status_code == 200
|
||||
assert "Projets/demo" in r.text
|
||||
assert "Tâche focus" in r.text # the in-progress card is on the wall
|
||||
assert 'class="wall-slide active"' in r.text
|
||||
assert "🧪 QA" in r.text # the six column counters include QA
|
||||
|
||||
|
||||
def test_wall_single_repo_mode(client):
|
||||
r = client.get("/wall", params={"repo": "Projets/demo"})
|
||||
assert r.status_code == 200
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_wall_unknown_repo_shows_empty_state(client):
|
||||
r = client.get("/wall", params={"repo": "Inconnu/xyz"})
|
||||
assert r.status_code == 200
|
||||
assert "Aucun projet actif" in r.text
|
||||
|
||||
|
||||
def test_wall_is_behind_auth(monkeypatch, fake_gitea):
|
||||
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
|
||||
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
|
||||
with TestClient(main.app) as secured:
|
||||
assert secured.get("/wall").status_code == 401
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Regression: real Gitea returns "pull_request": null on plain issues.
|
||||
|
||||
The 0.2.0–0.5.0 filter tested key *presence* ("pull_request" not in
|
||||
item), which classified every real issue as a PR and silently emptied
|
||||
every board against a real Gitea — while the test fake omitted the
|
||||
key entirely, so the suite stayed green. Found 2026-10-11 after a
|
||||
user report ("new issue does not appear on the board"): the live
|
||||
flowdeck board rendered 0 cards for 6 open issues.
|
||||
"""
|
||||
|
||||
from app.main import _is_issue
|
||||
|
||||
|
||||
def test_plain_issue_with_null_pull_request_is_an_issue():
|
||||
assert _is_issue({"number": 1, "pull_request": None}) is True
|
||||
|
||||
|
||||
def test_pull_request_object_is_not_an_issue():
|
||||
assert _is_issue({"number": 2, "pull_request": {"url": "http://x/pr/2"}}) is False
|
||||
|
||||
|
||||
def test_missing_key_is_an_issue():
|
||||
assert _is_issue({"number": 3}) is True
|
||||
|
||||
|
||||
def test_board_renders_cards_from_realistic_payload(client):
|
||||
# The shared fake now mirrors the real payload (key always
|
||||
# present, null on plain issues): if the filter regresses to a
|
||||
# presence test, these cards vanish and this test goes red.
|
||||
r = client.get("/board/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert "Tâche un" in r.text
|
||||
assert "Tâche focus" in r.text
|
||||
assert "PR déguisée" not in r.text
|
||||
Reference in New Issue
Block a user