diff --git a/app/main.py b/app/main.py index fdb8c74..0fbe265 100644 --- a/app/main.py +++ b/app/main.py @@ -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.""" diff --git a/app/templates/_palette.html b/app/templates/_palette.html new file mode 100644 index 0000000..73036dc --- /dev/null +++ b/app/templates/_palette.html @@ -0,0 +1,12 @@ +
diff --git a/app/templates/activity.html b/app/templates/activity.html index c212e68..498d2b9 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -21,7 +21,9 @@ 📊 Briefing 🔍 Recherche ⚡ Capture + 🧠 Digest 🖥️ Wallboard + @@ -59,5 +61,7 @@ Kanbante v{{ version }} · journal d'événements +{% include "_palette.html" %} +