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.
This commit is contained in:
Spark
2026-10-11 12:46:54 -04:00
parent 39b751df56
commit 0417d0593f
12 changed files with 517 additions and 0 deletions
+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."""