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 %}
+
+ {% 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 %}
+
+
+
+ Kanbante v{{ version }} · digest hebdo : composé automatiquement, livré seulement sur approbation
+
+ {% include "_palette.html" %}
+
+
+
+