Compare commits

..
4 Commits
Author SHA1 Message Date
Spark 1743c8c101 chore(release): Kanbante 0.8.0 — palette Ctrl+K et digest ObsiGate
CI / test (push) Successful in 1m43s
2026-10-11 12:46:54 -04:00
Spark 411c0d2934 test(0.8.0): palette (endpoint, markup) + digest (format, MCP mocké, envois) — 11 tests, 121 au total 2026-10-11 12:46:54 -04:00
Spark 6e71b87d09 feat(digest): digest hebdo vers ObsiGate via MCP, sur approbation (issue #13)
app/digest.py: format markdown (journal 7 j + focus), client MCP
(initialize/tools/list/tools/call, réponses JSON ou SSE), gabarit
d'arguments JSON configurable, webhook générique. Page /digest:
statut, sonde des outils, envoi en un clic. Planificateur: compose
+ notifie (kv digest_pending), ne livre JAMAIS seul.
2026-10-11 12:46:54 -04:00
Spark 0417d0593f feat(palette): palette de commandes Ctrl+K sur toutes les pages (issue #10)
GET /api/palette: projets (filtrage local) + recherche globale
d'issues annotées de leur colonne. Composant partagé _palette.html
+ palette.js (actions, projets, issues; ↑↓/Entrée/Échap; bouton
⌨️ dans la navigation). Aucune librairie ajoutée.
2026-10-11 12:46:54 -04:00
19 changed files with 1029 additions and 2 deletions
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## 0.8.0 — 2026-10-11
### Nouveautés
- **Palette de commandes** (issue #10) : `Ctrl+K` / `Cmd+K` (ou le
bouton ⌨️ dans la navigation) ouvre sur toutes les pages une
palette façon Linear/Raycast — actions de navigation, tous les
projets (filtrage local instantané) et recherche globale d'issues
annotées de leur colonne (nouvel endpoint `GET /api/palette?q=`).
Navigation entièrement au clavier (↑↓, Entrée, Échap).
- **Digest hebdo vers ObsiGate** (issue #13) : le résumé markdown
de la semaine (issues fermées enrichies de leurs titres, cartes en
cours/QA/révision, compteurs du journal) est composé
automatiquement le jour et l'heure choisis
(`KANBANTE_DIGEST_WEEKDAY` / `KANBANTE_DIGEST_TIME`) avec
notification ntfy — mais **jamais livré sans approbation** :
l'envoi se fait d'un clic sur `/digest`, vers le serveur MCP
d'ObsiGate (client MCP intégré : initialize + tools/call ; URL,
jeton, nom d'outil et gabarit JSON d'arguments configurables ;
bouton « Tester la connexion » qui liste les outils du serveur)
ou vers un webhook générique (`KANBANTE_DIGEST_WEBHOOK_URL`,
JSON `{title, markdown, week}`).
11 tests ajoutés (121 au total).
## 0.7.0 — 2026-10-11
### Nouveauté
+8
View File
@@ -16,6 +16,8 @@
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
- ⌨️ **Palette de commandes** — `Ctrl+K` (ou le bouton ⌨️) sur toutes les pages : actions, projets et recherche d'issues globale, 100 % clavier
- 🧠 **Digest hebdo → ObsiGate** — résumé markdown de la semaine (journal + focus) composé automatiquement, **livré seulement sur approbation** (page `/digest`) vers le serveur MCP d'ObsiGate (client MCP intégré, outil et gabarit d'arguments configurables) ou un webhook générique
- ⚡ **Capture éclair** — page `/capture` (formulaire), endpoint `POST /api/capture` protégé par un jeton dédié, et écoute ntfy : un message publié sur le topic de capture devient une issue dans le dépôt par défaut
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
@@ -116,6 +118,9 @@ sudo systemctl enable --now docker
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
| `KANBANTE_DIGEST_TIME` / `KANBANTE_DIGEST_WEEKDAY` | *(vide)* / `0` | Composition hebdo du digest (`HH:MM`, 0 = lundi) ; notification ntfy, jamais d'envoi automatique |
| `KANBANTE_OBSIGATE_MCP_URL` / `_TOKEN` / `_TOOL` / `_ARGS` | *(vide)* | Serveur MCP d'ObsiGate : URL, jeton optionnel, outil de création de note, gabarit JSON d'arguments (`{title}`, `{content}`) |
| `KANBANTE_DIGEST_WEBHOOK_URL` | *(vide)* | Webhook générique recevant le digest en JSON `{title, markdown, week}` |
## Sécurité
@@ -160,6 +165,9 @@ kanbante/
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
| GET | `/api/palette` | Données de la palette Ctrl+K (projets + recherche d'issues `?q=`) |
| GET | `/digest` | Digest hebdo : aperçu, statut des cibles, approbation d'envoi |
| POST | `/digest/send` | Livrer le digest (cible `obsigate` ou `webhook`) — approbation explicite |
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
+35 -1
View File
@@ -51,6 +51,23 @@ Environment variables:
an issue in KANBANTE_CAPTURE_REPO.
Uses KANBANTE_NTFY_URL/_TOKEN as the
server. Empty (default) = no listener.
KANBANTE_DIGEST_TIME "HH:MM" at which the weekly digest is
composed (and ntfy-notified) on
KANBANTE_DIGEST_WEEKDAY. The digest is
NEVER auto-delivered: it waits for a
one-click approval on /digest.
KANBANTE_DIGEST_WEEKDAY 0 = Monday … 6 = Sunday (default 0).
KANBANTE_DIGEST_WEBHOOK_URL Generic webhook receiving the digest
as JSON {title, markdown, week}.
KANBANTE_OBSIGATE_MCP_URL URL of ObsiGate's MCP endpoint; the
digest is delivered by calling a
note-creation tool on it (client MCP).
KANBANTE_OBSIGATE_MCP_TOKEN Optional Bearer token for that MCP.
KANBANTE_OBSIGATE_MCP_TOOL Name of the ObsiGate tool to call
(see /digest for the live tool list).
KANBANTE_OBSIGATE_MCP_ARGS JSON argument template for the tool;
"{title}" and "{content}" placeholders
are substituted (JSON-escaped).
"""
import os
@@ -59,7 +76,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.7.0"
APP_VERSION = "0.8.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -118,6 +135,13 @@ class Settings:
capture_repo: str = ""
capture_token: str = ""
capture_ntfy_topic: str = ""
digest_time: str = ""
digest_weekday: int = 0
digest_webhook_url: str = ""
obsigate_mcp_url: str = ""
obsigate_mcp_token: str = ""
obsigate_mcp_tool: str = ""
obsigate_mcp_args: str = '{"title": "{title}", "content": "{content}"}'
@property
def auth_enabled(self) -> bool:
@@ -150,4 +174,14 @@ def get_settings() -> Settings:
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
digest_time=os.getenv("KANBANTE_DIGEST_TIME", ""),
digest_weekday=_env_int("KANBANTE_DIGEST_WEEKDAY", 0),
digest_webhook_url=os.getenv("KANBANTE_DIGEST_WEBHOOK_URL", ""),
obsigate_mcp_url=os.getenv("KANBANTE_OBSIGATE_MCP_URL", ""),
obsigate_mcp_token=os.getenv("KANBANTE_OBSIGATE_MCP_TOKEN", ""),
obsigate_mcp_tool=os.getenv("KANBANTE_OBSIGATE_MCP_TOOL", ""),
obsigate_mcp_args=os.getenv(
"KANBANTE_OBSIGATE_MCP_ARGS",
'{"title": "{title}", "content": "{content}"}',
),
)
+188
View File
@@ -0,0 +1,188 @@
"""Weekly digest (roadmap #13): the week in Kanbante, as a note.
Composes a markdown digest from the event journal (last 7 days) and
the current focus snapshot, then — ONLY after a human approves on
/digest (standing rule: cross-app writes need approval) — delivers
it to ObsiGate through its MCP server (Kanbante as MCP *client*:
initialize → tools/call) or to a generic JSON webhook.
The ObsiGate tool contract is configurable (URL, tool name, JSON
argument template with {title}/{content} placeholders) because the
exact tool names of the target server are an operator decision, not
something to hardcode blindly.
"""
import json
import logging
import httpx
logger = logging.getLogger("kanbante.digest")
MCP_PROTOCOL_VERSION = "2024-11-05"
def format_digest_markdown(
week_label: str,
closed_items: list[dict],
created_count: int,
moved_count: int,
focus_groups: list[dict],
) -> str:
"""Render the weekly digest as markdown (Obsidian-friendly)."""
lines = [f"# 🧠 Kanbante — semaine du {week_label}", ""]
lines.append(f"## ✅ Livré ({len(closed_items)})")
if closed_items:
for item in closed_items:
title = item.get("title") or ""
lines.append(
f"- [{item['repo']}#{item['number']}]({item.get('html_url', '')})"
f" {title}".rstrip()
)
else:
lines.append("- Rien n'a été fermé cette semaine.")
lines.append("")
lines.append("## 🚧 En cours")
total_focus = sum(len(g["cards"]) for g in focus_groups)
if total_focus:
for group in focus_groups:
lines.append(f"### {group['full_name']}")
for card in group["cards"]:
issue = card["issue"]
lines.append(
f"- #{issue['number']} {issue.get('title', '')}"
f" — *{card['column']}*"
)
else:
lines.append("- Aucune carte en cours, en QA ou en révision.")
lines.append("")
lines.append("## 📈 Activité de la semaine")
lines.append(f"- Issues créées : {created_count}")
lines.append(f"- Déplacements de cartes : {moved_count}")
lines.append(f"- Issues fermées : {len(closed_items)}")
lines.append("")
lines.append("*Digest composé par Kanbante à partir de son journal d'événements.*")
return "\n".join(lines)
def render_mcp_args(template: str, title: str, content: str) -> dict:
"""Substitute {title}/{content} in a JSON template, safely."""
def esc(value: str) -> str:
return json.dumps(value, ensure_ascii=False)[1:-1]
rendered = template.replace("{title}", esc(title)).replace(
"{content}", esc(content)
)
try:
args = json.loads(rendered)
except ValueError as exc:
raise ValueError(f"KANBANTE_OBSIGATE_MCP_ARGS is not valid JSON: {exc}")
if not isinstance(args, dict):
raise ValueError("KANBANTE_OBSIGATE_MCP_ARGS must be a JSON object")
return args
async def send_webhook(url: str, title: str, markdown: str, week: str) -> tuple[bool, str]:
"""POST the digest as JSON to a generic webhook."""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
url, json={"title": title, "markdown": markdown, "week": week}
)
if response.status_code < 300:
return True, f"webhook HTTP {response.status_code}"
return False, f"webhook HTTP {response.status_code}: {response.text[:200]}"
except Exception as exc:
return False, f"webhook error: {exc}"
def _mcp_headers(token: str) -> dict:
headers = {
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _parse_mcp_body(response: httpx.Response) -> dict:
"""MCP streamable HTTP answers JSON or an SSE frame — accept both."""
text = response.text.strip()
if not text:
return {}
if text.startswith("{"):
return json.loads(text)
payload: dict = {}
for line in text.splitlines():
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk.startswith("{"):
payload = json.loads(chunk)
return payload
async def _mcp_rpc(client: httpx.AsyncClient, url: str, token: str,
method: str, params: dict, req_id: int) -> dict:
response = await client.post(
url,
json={"jsonrpc": "2.0", "id": req_id, "method": method, "params": params},
headers=_mcp_headers(token),
)
response.raise_for_status()
payload = _parse_mcp_body(response)
if "error" in payload:
err = payload["error"] or {}
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
return payload.get("result") or {}
async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
await _mcp_rpc(
client, url, token, "initialize",
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.8.0"},
},
req_id=1,
)
await client.post(
url,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
headers=_mcp_headers(token),
)
async def mcp_list_tools(url: str, token: str) -> tuple[bool, list[str] | str]:
"""(True, [tool names]) or (False, error message)."""
try:
async with httpx.AsyncClient(timeout=20) as client:
await _mcp_session(client, url, token)
result = await _mcp_rpc(client, url, token, "tools/list", {}, req_id=2)
return True, [t.get("name", "?") for t in result.get("tools", [])]
except Exception as exc:
return False, f"MCP tools/list failed: {exc}"
async def mcp_call_tool(
url: str, token: str, tool: str, arguments: dict
) -> tuple[bool, str]:
"""Call one tool; (True, short result) or (False, error message)."""
try:
async with httpx.AsyncClient(timeout=30) as client:
await _mcp_session(client, url, token)
result = await _mcp_rpc(
client, url, token, "tools/call",
{"name": tool, "arguments": arguments}, req_id=3,
)
if result.get("isError"):
return False, f"tool {tool} reported an error: {str(result)[:300]}"
texts = [
block.get("text", "")
for block in result.get("content", [])
if isinstance(block, dict) and block.get("type") == "text"
]
return True, (" ".join(texts)[:300] or f"tool {tool} OK")
except Exception as exc:
return False, f"MCP tools/call failed: {exc}"
+249
View File
@@ -34,6 +34,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app import digest as digest_service
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
from app.capture import ntfy_capture_loop, parse_capture_text
from app.models import (
@@ -140,6 +141,7 @@ async def lifespan(app: FastAPI):
"GITEA_TOKEN is not set: only public Gitea content will be visible."
)
scheduler = asyncio.create_task(_briefing_scheduler())
digest_task = asyncio.create_task(_digest_scheduler())
capture_task = None
if settings.capture_ntfy_topic and settings.ntfy_url:
async def _capture_create(title: str, body: str) -> None:
@@ -162,6 +164,7 @@ async def lifespan(app: FastAPI):
)
yield
scheduler.cancel()
digest_task.cancel()
if capture_task is not None:
capture_task.cancel()
await gitea.aclose()
@@ -1176,6 +1179,199 @@ async def capture_submit(
)
async def _weekly_digest() -> dict:
"""Compose the weekly digest (journal of the last 7 days + focus)."""
now = datetime.now(timezone.utc)
since = (now - timedelta(days=7)).replace(tzinfo=None)
events = []
for event in get_events(limit=1000):
at = _parse_datetime(str(event.get("created_at") or ""))
if at is not None and at.replace(tzinfo=None) >= since:
events.append(event)
closed_keys: list[tuple[str, int]] = []
seen: set[tuple[str, int]] = set()
for event in events:
if event["event_type"] == "closed":
key = (event["repo_full_name"], event["issue_number"])
if key not in seen:
seen.add(key)
closed_keys.append(key)
titles: dict[tuple[str, int], dict] = {}
for repo_name in {repo for repo, _ in closed_keys}:
owner, _, name = repo_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="all")
except Exception:
continue
for item in items:
if _is_issue(item):
titles[(repo_name, item["number"])] = item
closed_items = [
{
"repo": repo,
"number": number,
"title": (titles.get((repo, number)) or {}).get("title", ""),
"html_url": (titles.get((repo, number)) or {}).get("html_url", ""),
}
for repo, number in closed_keys
]
focus_groups = []
for group in await _collect_focus_groups():
focus_groups.append(
{
"full_name": group["full_name"],
"cards": [
{
"issue": card["issue"],
"column": COLUMN_LABELS[card["column"]],
}
for card in group["cards"]
],
}
)
week_label = (
f"{(now - timedelta(days=6)).strftime('%Y-%m-%d')}"
f" au {now.strftime('%Y-%m-%d')}"
)
markdown = digest_service.format_digest_markdown(
week_label,
closed_items,
created_count=sum(1 for e in events if e["event_type"] == "created"),
moved_count=sum(1 for e in events if e["event_type"] == "moved"),
focus_groups=focus_groups,
)
return {
"title": f"Kanbante — semaine du {week_label}",
"week": now.strftime("%G-W%V"),
"markdown": markdown,
"closed_count": len(closed_items),
}
def _render_digest(request: Request, banner: str | None = None,
tools: list[str] | None = None):
settings = get_settings()
return render(
request,
"digest.html",
pending=get_kv("digest_pending"),
last_result=get_kv("digest_last_result"),
obsigate_configured=bool(settings.obsigate_mcp_url),
obsigate_url=settings.obsigate_mcp_url,
obsigate_tool=settings.obsigate_mcp_tool,
webhook_configured=bool(settings.digest_webhook_url),
digest_time=settings.digest_time,
digest_weekday=settings.digest_weekday,
banner=banner,
tools=tools,
)
@app.get("/digest", response_class=HTMLResponse)
async def digest_page(request: Request):
"""Weekly digest: preview, approval and delivery status."""
return _render_digest(request)
@app.post("/digest/probe", response_class=HTMLResponse)
async def digest_probe(request: Request, csrf_token: str = Form("")):
"""List the tools of the configured ObsiGate MCP server."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
if not settings.obsigate_mcp_url:
return _render_digest(request, banner="❌ Aucun serveur MCP ObsiGate configuré (KANBANTE_OBSIGATE_MCP_URL).")
ok, result = await digest_service.mcp_list_tools(
settings.obsigate_mcp_url, settings.obsigate_mcp_token
)
if ok:
return _render_digest(
request,
banner=f"✅ Connexion MCP réussie — {len(result)} outil(s) disponible(s).",
tools=result,
)
return _render_digest(request, banner=f"❌ {result}")
@app.post("/digest/send", response_class=HTMLResponse)
async def digest_send(
request: Request,
target: str = Form(...),
csrf_token: str = Form(""),
):
"""Deliver the digest — only ever from this explicit approval."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
digest = await _weekly_digest()
if target == "webhook":
if not settings.digest_webhook_url:
return _render_digest(request, banner="❌ Webhook non configuré (KANBANTE_DIGEST_WEBHOOK_URL).")
ok, message = await digest_service.send_webhook(
settings.digest_webhook_url,
digest["title"], digest["markdown"], digest["week"],
)
elif target == "obsigate":
if not settings.obsigate_mcp_url or not settings.obsigate_mcp_tool:
return _render_digest(request, banner="❌ ObsiGate incomplet : KANBANTE_OBSIGATE_MCP_URL et KANBANTE_OBSIGATE_MCP_TOOL sont requis (utilise « Tester la connexion » pour voir les outils).")
try:
arguments = digest_service.render_mcp_args(
settings.obsigate_mcp_args, digest["title"], digest["markdown"]
)
except ValueError as exc:
return _render_digest(request, banner=f"❌ {exc}")
ok, message = await digest_service.mcp_call_tool(
settings.obsigate_mcp_url, settings.obsigate_mcp_token,
settings.obsigate_mcp_tool, arguments,
)
else:
return _render_digest(request, banner="❌ Cible inconnue.")
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
set_kv("digest_last_result", f"{stamp} · {target} · {message}")
if ok:
set_kv("digest_pending", "")
return _render_digest(request, banner=f"✅ Digest envoyé ({target}) : {message}")
return _render_digest(request, banner=f"❌ Échec de l'envoi ({target}) : {message}")
async def _digest_scheduler() -> None:
"""Compose the weekly digest and notify — never deliver it.
Delivery to ObsiGate is a cross-app write: it waits for the
one-click approval on /digest (standing rule).
"""
while True:
try:
settings = get_settings()
if settings.digest_time:
now = datetime.now()
week = now.strftime("%G-W%V")
if (
now.weekday() == settings.digest_weekday
and now.strftime("%H:%M") >= settings.digest_time
and get_kv("digest_pending_week") != week
):
digest = await _weekly_digest()
set_kv("digest_pending", digest["markdown"])
set_kv("digest_pending_week", week)
logger.info("Weekly digest composed for %s", week)
if ntfy_configured(settings):
await send_ntfy(
settings,
"🧠 Digest hebdo Kanbante prêt — "
"en attente d'approbation sur la page /digest.",
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Digest scheduler error: %s", exc)
await asyncio.sleep(60)
@app.get("/api/stream")
async def live_stream(request: Request):
"""Server-Sent Events: browsers reload softly on Gitea activity."""
@@ -1806,6 +2002,59 @@ async def api_stats():
return get_stats()
@app.get("/api/palette")
async def api_palette(q: str = ""):
"""Data for the Ctrl+K command palette (roadmap #10).
Projects are always returned (the palette filters them locally);
issues are searched globally once the query has 2+ characters,
annotated with their current board column.
"""
repos = await gitea.search_repos()
active, inactive, _archived = classify_repos(repos)
projects = [
{"full_name": r["full_name"]} for r in (active + inactive)[:60]
]
issues: list[dict] = []
query = q.strip()
if len(query) >= 2:
try:
found = await gitea.search_issues(query, state="all")
except Exception as exc:
logger.debug("palette search failed: %s", exc)
found = []
placements: dict[str, dict[int, str]] = {}
for item in found[:40]:
if not _is_issue(item):
continue
full = (item.get("repository") or {}).get("full_name", "")
if not full:
continue
if full not in placements:
placements[full] = {
card["issue_number"]: col
for col, cards in get_board_state(full).items()
for card in cards
}
if item.get("state") == "closed":
column = "done"
else:
column = placements[full].get(item["number"], "backlog")
issues.append(
{
"repo": full,
"number": item["number"],
"title": item.get("title", ""),
"state": item.get("state", "open"),
"column": column,
"column_label": COLUMN_LABELS[column],
}
)
if len(issues) >= 8:
break
return {"projects": projects, "issues": issues}
@app.get("/api/projects")
async def api_projects():
"""Get all projects (JSON), classified like the dashboard."""
+12
View File
@@ -0,0 +1,12 @@
<div class="palette-overlay" id="palette-overlay" hidden>
<div class="palette" role="dialog" aria-modal="true" aria-label="Palette de commandes">
<div class="palette-input-row">
<span class="palette-icon">⌨️</span>
<input id="palette-input" type="text" autocomplete="off" spellcheck="false"
placeholder="Chercher un projet, une issue, une action…">
<kbd>Échap</kbd>
</div>
<ul class="palette-results" id="palette-results"></ul>
<div class="palette-hint">↑↓ naviguer · ↵ ouvrir · Échap fermer · Ctrl+K pour rouvrir</div>
</div>
</div>
+4
View File
@@ -21,7 +21,9 @@
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -59,5 +61,7 @@
Kanbante v{{ version }} · journal d'événements
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -24,7 +24,9 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
@@ -205,5 +207,7 @@
{% endif %}
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -21,7 +21,9 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -69,5 +71,7 @@
status.textContent = data.ok ? '✅ Briefing envoyé sur ntfy.' : '❌ ' + (data.error || ('HTTP ' + r.status));
});
</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -18,7 +18,9 @@
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill active">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -96,5 +98,7 @@
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -21,7 +21,9 @@
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -134,5 +136,7 @@
}
});
</script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>Digest hebdo — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<header class="app-header">
<a href="/" class="logo">📋 Kanbante</a>
<nav class="nav-pills">
<a href="/" class="nav-pill">🏠 Projets</a>
<a href="/focus" class="nav-pill">🎯 Focus</a>
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill active">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
<main class="focus-main">
<section class="briefing-card">
<h1>🧠 Digest hebdo → ObsiGate</h1>
<p>Chaque semaine, Kanbante compose le résumé de l'activité (livré, en cours, chiffres du journal) en une note markdown.
L'envoi vers ObsiGate est une écriture dans un autre système : <strong>il n'a jamais lieu sans ton approbation ici</strong>.</p>
{% if last_result %}<p>🧾 Dernier envoi : <code>{{ last_result }}</code></p>{% endif %}
{% if digest_time %}<p>🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).</p>{% endif %}
</section>
{% if banner %}
<div class="briefing-card"><p>{{ banner }}</p></div>
{% endif %}
{% if tools is not none %}
<div class="briefing-card">
<h2>🛠️ Outils du serveur MCP ObsiGate</h2>
{% if tools %}
<ul>
{% for tool in tools %}<li><code>{{ tool }}</code></li>{% endfor %}
</ul>
<p>Choisis l'outil de création de note, puis définis <code>KANBANTE_OBSIGATE_MCP_TOOL</code> (et au besoin <code>KANBANTE_OBSIGATE_MCP_ARGS</code>, gabarit JSON avec <code>{title}</code> et <code>{content}</code>).</p>
{% else %}
<p>Le serveur répond, mais n'expose aucun outil.</p>
{% endif %}
</div>
{% endif %}
<div class="briefing-card">
<h2>📤 Livraison</h2>
<p>
ObsiGate (MCP) : {% if obsigate_configured %}<strong>configuré</strong> — <code>{{ obsigate_url }}</code>, outil <code>{{ obsigate_tool or "(à définir)" }}</code>{% else %}non configuré (<code>KANBANTE_OBSIGATE_MCP_URL</code>){% endif %}<br>
Webhook générique : {% if webhook_configured %}<strong>configuré</strong>{% else %}non configuré (<code>KANBANTE_DIGEST_WEBHOOK_URL</code>){% endif %}
</p>
<form method="post" action="/digest/probe" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-save">🔌 Tester la connexion MCP</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="obsigate">
<button type="submit" class="btn-save">🧠 Envoyer vers ObsiGate</button>
</form>
<form method="post" action="/digest/send" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="webhook">
<button type="submit" class="btn-save">🔗 Envoyer au webhook</button>
</form>
</div>
<div class="briefing-card">
<h2>👀 Aperçu du digest de la semaine</h2>
{% if pending %}
<pre class="capture-pre">{{ pending }}</pre>
{% else %}
<p>Aucun digest en attente. L'aperçu exact est composé au moment de l'envoi (boutons ci-dessus) à partir du journal des 7 derniers jours et de la vue Focus.</p>
{% endif %}
</div>
</main>
<footer>
<span>Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation</span>
</footer>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
<script src="/static/js/live.js" defer></script>
</body>
</html>
+4
View File
@@ -21,7 +21,9 @@
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
</nav>
</header>
@@ -70,5 +72,7 @@
Kanbante v{{ version }} · vue Focus
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -20,7 +20,9 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -61,5 +63,7 @@
Kanbante v{{ version }} · mes issues
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+4
View File
@@ -20,7 +20,9 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill active">🔍 Recherche</a>
<a href="/capture" class="nav-pill">⚡ Capture</a>
<a href="/digest" class="nav-pill">🧠 Digest</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
</nav>
</header>
@@ -67,5 +69,7 @@
Kanbante v{{ version }} · recherche globale
</footer>
<script src="/static/js/live.js" defer></script>
{% include "_palette.html" %}
<script src="/static/js/palette.js" defer></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.7.0"
version = "0.8.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+46
View File
@@ -980,3 +980,49 @@ footer {
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
}
/* ── Palette de commandes (0.8.0, #10) ── */
.palette-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(5, 10, 20, 0.62);
backdrop-filter: blur(3px);
display: flex; justify-content: center; align-items: flex-start;
padding: 10vh 16px 16px;
}
.palette-overlay[hidden] { display: none; }
.palette {
width: min(640px, 100%);
background: var(--surface-1, #131a29);
border: 1px solid var(--border-strong, #2c3a52);
border-radius: 16px;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
overflow: hidden;
animation: wall-in 0.15s ease;
}
.palette-input-row {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-bottom: 1px solid var(--border, #22304a);
}
.palette-input-row input {
flex: 1; background: transparent; border: none; outline: none;
color: var(--text-1); font: inherit; font-size: 1.05rem;
}
.palette-input-row kbd {
background: var(--surface-3, #1d2942); border: 1px solid var(--border, #22304a);
border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; color: var(--text-3);
}
.palette-results { list-style: none; margin: 0; padding: 8px; max-height: 46vh; overflow-y: auto; }
.palette-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 10px; cursor: pointer;
}
.palette-item.active { background: var(--surface-3, #1d2942); }
.palette-item-icon { font-size: 1.05rem; width: 24px; text-align: center; }
.palette-item-main { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-item-sub { color: var(--text-3); font-size: 0.8rem; white-space: nowrap; }
.palette-empty { padding: 18px 14px; color: var(--text-3); text-align: center; }
.palette-hint {
padding: 9px 16px; border-top: 1px solid var(--border, #22304a);
color: var(--text-3); font-size: 0.78rem; text-align: right;
}
.palette-trigger { cursor: pointer; font: inherit; }
+178
View File
@@ -0,0 +1,178 @@
/**
* Kanbante — Command palette (Ctrl+K), roadmap #10.
*
* One overlay for the whole app: static actions, every project, and
* global issue search (via /api/palette). Fully keyboard driven.
*/
(function () {
var overlay = document.getElementById('palette-overlay');
if (!overlay) return;
var input = document.getElementById('palette-input');
var list = document.getElementById('palette-results');
var ACTIONS = [
{ icon: '🏠', label: 'Projets — dashboard', href: '/' },
{ icon: '🎯', label: 'Focus — en cours, QA, révision', href: '/focus' },
{ icon: '👤', label: 'Mes issues', href: '/my' },
{ icon: '📰', label: 'Activité — journal des événements', href: '/activity' },
{ icon: '📊', label: 'Briefing quotidien', href: '/briefing' },
{ icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' },
{ icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' },
{ icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' },
{ icon: '🔍', label: 'Recherche avancée', href: '/search' },
];
var projects = null; // cached after first fetch
var issues = []; // latest search results for the query
var items = []; // rendered items
var activeIndex = 0;
var debounce = null;
var lastQuery = '';
function open() {
overlay.hidden = false;
document.body.style.overflow = 'hidden';
input.value = '';
issues = [];
lastQuery = '';
render('');
input.focus();
if (projects === null) {
fetch('/api/palette')
.then(function (r) { return r.json(); })
.then(function (data) {
projects = data.projects || [];
render(input.value.trim());
})
.catch(function () { projects = []; });
}
}
function close() {
overlay.hidden = true;
document.body.style.overflow = '';
}
function buildItems(query) {
var q = query.toLowerCase();
var out = [];
ACTIONS.forEach(function (a) {
if (!q || a.label.toLowerCase().includes(q)) {
out.push({ icon: a.icon, label: a.label, sub: 'Action', href: a.href });
}
});
(projects || []).forEach(function (p) {
if (out.length >= 14) return;
if (!q || p.full_name.toLowerCase().includes(q)) {
out.push({
icon: '📂', label: p.full_name, sub: 'Projet · ouvrir le board',
href: '/board/' + p.full_name,
});
}
});
issues.forEach(function (i) {
out.push({
icon: '🐛',
label: '#' + i.number + ' · ' + i.title,
sub: i.repo + ' · ' + i.column_label + (i.state === 'closed' ? ' · fermée' : ''),
href: '/board/' + i.repo,
});
});
return out.slice(0, 14);
}
function render(query) {
items = buildItems(query);
activeIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
list.innerHTML = '';
if (!items.length) {
var empty = document.createElement('li');
empty.className = 'palette-empty';
empty.textContent = 'Aucun résultat — essaie un nom de projet ou un mot du titre d\'une issue.';
list.appendChild(empty);
return;
}
items.forEach(function (item, idx) {
var li = document.createElement('li');
li.className = 'palette-item' + (idx === activeIndex ? ' active' : '');
var icon = document.createElement('span');
icon.className = 'palette-item-icon';
icon.textContent = item.icon;
var main = document.createElement('span');
main.className = 'palette-item-main';
main.textContent = item.label;
var sub = document.createElement('span');
sub.className = 'palette-item-sub';
sub.textContent = item.sub;
li.appendChild(icon);
li.appendChild(main);
li.appendChild(sub);
li.addEventListener('click', function () { go(item); });
li.addEventListener('mousemove', function () {
if (activeIndex !== idx) { activeIndex = idx; render(input.value.trim()); }
});
list.appendChild(li);
});
}
function go(item) {
if (item) window.location.href = item.href;
}
function searchIssues(query) {
if (query.length < 2) { issues = []; render(query); return; }
fetch('/api/palette?q=' + encodeURIComponent(query))
.then(function (r) { return r.json(); })
.then(function (data) {
if (input.value.trim() !== query) return; // stale response
issues = data.issues || [];
if (projects === null) projects = data.projects || [];
render(query);
})
.catch(function () { /* keep previous results */ });
}
input.addEventListener('input', function () {
var query = input.value.trim();
activeIndex = 0;
render(query);
if (query !== lastQuery) {
lastQuery = query;
clearTimeout(debounce);
debounce = setTimeout(function () { searchIssues(query); }, 180);
}
});
input.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
render(input.value.trim());
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
render(input.value.trim());
} else if (e.key === 'Enter') {
e.preventDefault();
go(items[activeIndex]);
}
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay) close();
});
document.addEventListener('keydown', function (e) {
var combo = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k';
if (combo) {
e.preventDefault();
if (overlay.hidden) open(); else close();
} else if (e.key === 'Escape' && !overlay.hidden) {
close();
}
});
document.querySelectorAll('.palette-trigger').forEach(function (btn) {
btn.addEventListener('click', function () { open(); });
});
})();
+164
View File
@@ -0,0 +1,164 @@
"""Palier 5 (0.8.0): palette de commandes (#10) + digest hebdo (#13)."""
import httpx
import pytest
from app import digest as digest_service
from app import models
from tests.conftest import csrf_from
# ── Palette (#10) ─────────────────────────────────────────────────
def test_palette_endpoint_projects_and_issues(client, fake_gitea):
r = client.get("/api/palette", params={"q": "tâche"})
assert r.status_code == 200
data = r.json()
assert any(p["full_name"] == "Projets/demo" for p in data["projects"])
titles = [i["title"] for i in data["issues"]]
assert "Tâche un" in titles
found = next(i for i in data["issues"] if i["title"] == "Tâche un")
assert found["repo"] == "Projets/demo"
assert found["column"] == "backlog"
def test_palette_short_query_skips_issue_search(client):
r = client.get("/api/palette", params={"q": "x"})
assert r.status_code == 200
assert r.json()["issues"] == []
assert r.json()["projects"] # projects are always there
def test_palette_markup_on_pages(client):
for path in ("/", "/focus", "/capture"):
r = client.get(path)
assert r.status_code == 200
assert 'id="palette-overlay"' in r.text, path
assert "/static/js/palette.js" in r.text, path
assert "palette-trigger" in r.text, path
# ── Digest : composition (#13) ────────────────────────────────────
def test_digest_markdown_format():
md = digest_service.format_digest_markdown(
"2026-10-05 au 2026-10-11",
[{"repo": "Projets/demo", "number": 3, "title": "Ancienne tâche",
"html_url": "http://gitea.test/x"}],
created_count=2,
moved_count=5,
focus_groups=[{
"full_name": "Projets/demo",
"cards": [{"issue": {"number": 5, "title": "Tâche focus"},
"column": "🚧 En cours"}],
}],
)
assert "# 🧠 Kanbante — semaine du 2026-10-05" in md
assert "## ✅ Livré (1)" in md
assert "Projets/demo#3" in md
assert "Tâche focus" in md
assert "Déplacements de cartes : 5" in md
def test_render_mcp_args_escapes_content():
args = digest_service.render_mcp_args(
'{"title": "{title}", "content": "{content}", "folder": "Kanbante"}',
'Titre "cité"',
"Ligne 1\nLigne 2 avec \\ barre",
)
assert args["title"] == 'Titre "cité"'
assert args["content"] == "Ligne 1\nLigne 2 avec \\ barre"
assert args["folder"] == "Kanbante"
with pytest.raises(ValueError):
digest_service.render_mcp_args("{pas json", "t", "c")
def test_digest_page_renders(client):
r = client.get("/digest")
assert r.status_code == 200
assert "Digest hebdo" in r.text
assert "approbation" in r.text
# ── Digest : livraison ────────────────────────────────────────────
def _mcp_handler_factory(calls: list):
def handler(request: httpx.Request) -> httpx.Response:
import json as _json
payload = _json.loads(request.content.decode() or "{}")
calls.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 1,
"result": {"protocolVersion": "2024-11-05", "capabilities": {}}})
if method == "notifications/initialized":
return httpx.Response(202, json={})
if method == "tools/list":
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 2,
"result": {"tools": [{"name": "create_note"}, {"name": "search"}]}})
if method == "tools/call":
calls.append(payload["params"])
return httpx.Response(200, json={"jsonrpc": "2.0", "id": 3,
"result": {"content": [{"type": "text", "text": "note créée"}]}})
return httpx.Response(200, json={})
return handler
@pytest.fixture
def mcp_transport(monkeypatch):
calls: list = []
transport = httpx.MockTransport(_mcp_handler_factory(calls))
real_async_client = httpx.AsyncClient
def factory(*args, **kwargs):
kwargs["transport"] = transport
return real_async_client(*args, **kwargs)
monkeypatch.setattr(httpx, "AsyncClient", factory)
return calls
async def test_mcp_list_tools(mcp_transport):
ok, tools = await digest_service.mcp_list_tools("http://obsigate.test/mcp", "")
assert ok is True
assert tools == ["create_note", "search"]
async def test_mcp_call_tool(mcp_transport):
ok, message = await digest_service.mcp_call_tool(
"http://obsigate.test/mcp", "", "create_note",
{"title": "T", "content": "C"},
)
assert ok is True
assert "note créée" in message
call = next(c for c in mcp_transport if isinstance(c, dict))
assert call["name"] == "create_note"
assert call["arguments"] == {"title": "T", "content": "C"}
def test_digest_send_to_obsigate(client, fake_gitea, monkeypatch, mcp_transport):
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_TOOL", "create_note")
token = csrf_from(client)
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
assert r.status_code == 200
assert "✅ Digest envoyé (obsigate)" in r.text
assert models.get_kv("digest_last_result")
def test_digest_send_requires_configuration(client, fake_gitea):
token = csrf_from(client)
r = client.post("/digest/send", data={"target": "obsigate", "csrf_token": token})
assert r.status_code == 200
assert "❌ ObsiGate incomplet" in r.text
def test_digest_probe_lists_tools(client, fake_gitea, monkeypatch, mcp_transport):
monkeypatch.setenv("KANBANTE_OBSIGATE_MCP_URL", "http://obsigate.test/mcp")
token = csrf_from(client)
r = client.post("/digest/probe", data={"csrf_token": token})
assert r.status_code == 200
assert "create_note" in r.text