From 6e71b87d09824cfeccd7a289665c38e875827279 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 12:46:54 -0400 Subject: [PATCH] feat(digest): digest hebdo vers ObsiGate via MCP, sur approbation (issue #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/config.py | 36 +++++++- app/digest.py | 188 ++++++++++++++++++++++++++++++++++++++ app/templates/digest.html | 92 +++++++++++++++++++ 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 app/digest.py create mode 100644 app/templates/digest.html diff --git a/app/config.py b/app/config.py index 68858ad..0bf05e0 100644 --- a/app/config.py +++ b/app/config.py @@ -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}"}', + ), ) diff --git a/app/digest.py b/app/digest.py new file mode 100644 index 0000000..229ae7f --- /dev/null +++ b/app/digest.py @@ -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}" diff --git a/app/templates/digest.html b/app/templates/digest.html new file mode 100644 index 0000000..c09f215 --- /dev/null +++ b/app/templates/digest.html @@ -0,0 +1,92 @@ + + + + + + + Digest hebdo — Kanbante + + + +
+ + +
+ +
+
+

🧠 Digest hebdo → ObsiGate

+

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 : il n'a jamais lieu sans ton approbation ici.

+ {% if last_result %}

🧾 Dernier envoi : {{ last_result }}

{% endif %} + {% if digest_time %}

🕐 Composition automatique : jour {{ digest_weekday }} à {{ digest_time }} (notification ntfy, envoi manuel ensuite).

{% endif %} +
+ + {% if banner %} +

{{ banner }}

+ {% endif %} + + {% if tools is not none %} +
+

🛠️ Outils du serveur MCP ObsiGate

+ {% if tools %} +
    + {% for tool in tools %}
  • {{ tool }}
  • {% endfor %} +
+

Choisis l'outil de création de note, puis définis KANBANTE_OBSIGATE_MCP_TOOL (et au besoin KANBANTE_OBSIGATE_MCP_ARGS, gabarit JSON avec {title} et {content}).

+ {% else %} +

Le serveur répond, mais n'expose aucun outil.

+ {% endif %} +
+ {% endif %} + +
+

📤 Livraison

+

+ ObsiGate (MCP) : {% if obsigate_configured %}configuré — {{ obsigate_url }}, outil {{ obsigate_tool or "(à définir)" }}{% else %}non configuré (KANBANTE_OBSIGATE_MCP_URL){% endif %}
+ Webhook générique : {% if webhook_configured %}configuré{% else %}non configuré (KANBANTE_DIGEST_WEBHOOK_URL){% endif %} +

+
+ + +
+
+ + + +
+
+ + + +
+
+ +
+

👀 Aperçu du digest de la semaine

+ {% if pending %} +
{{ pending }}
+ {% else %} +

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.

+ {% endif %} +
+
+ + {% include "_palette.html" %} + + + +