"""Daily briefing: formatting and ntfy delivery. The data itself is gathered in app.main (it needs the board helpers); this module only turns it into a readable text and pushes it to an ntfy topic. Kept dependency-light so it is unit-testable alone. """ from __future__ import annotations import httpx from app.config import Settings def ntfy_configured(settings: Settings) -> bool: return bool(settings.ntfy_url and settings.ntfy_topic) def format_standup_text(data: dict) -> str: """Automatic standup (issue #6): yesterday / today / blockers.""" lines = [f"🗣️ Standup — {data['today_label']}"] closed = data["closed_yesterday"] lines.append( f"\n✅ Hier ({data['yesterday_label']}) : {len(closed)} fermée(s), " f"{data['moved_yesterday']} déplacement(s)" ) for item in closed[:8]: lines.append( f" • {item['repo']} #{item['number']} « {item['title']} »" ) lines.append("\n📌 Aujourd'hui :") if data["picks"]: for pick in data["picks"]: lines.append( f" • {pick['repo']} #{pick['number']} « {pick['title']} »" f" — {pick['column_label']}" ) elif data["focus_titles"]: for title in data["focus_titles"][:5]: lines.append(f" • {title}") lines.append(" (aucun focus épinglé — pense à /today)") else: lines.append(" • Rien en cours. Journée libre ou triage à faire !") lines.append("\n🚧 Blocages :") if data["blockers"]: for card in data["blockers"][:6]: lines.append( f" • {card['repo']} #{card['number']} « {card['title']} »" f" — {card['days']} j en {card['column_label']}" ) else: lines.append(" • Aucun blocage signalé. ✨") return "\n".join(lines) def format_briefing_text(data: dict) -> str: """Plain-text briefing (ntfy body, also shown on /briefing).""" lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"] activity = data["activity_24h"] lines.append( f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), " f"{activity['created']} créée(s), {activity['moved']} déplacement(s)." ) if activity.get("avg_cycle_days") is not None: lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.") if data.get("today_picks"): lines.append("\n🌅 Focus du jour :") for pick in data["today_picks"]: mark = "✅" if pick["state"] == "closed" else "📌" lines.append( f" {mark} {pick['repo']} #{pick['number']} « {pick['title']} »" f" — {pick['column_label']}" ) if data.get("carry_over"): lines.append("\n↩️ À reprendre d'hier (non terminées) :") for pick in data["carry_over"]: lines.append( f" • {pick['repo']} #{pick['number']} « {pick['title']} »" f" — {pick['column_label']}" ) lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)") for group in data["focus_groups"][:6]: lines.append(f" • {group['full_name']} : {group['count']}") for title in group["titles"][:3]: lines.append(f" – {title}") if data["aging"]: lines.append("\n⏳ Cartes qui vieillissent :") for card in data["aging"][:8]: lines.append( f" • {card['repo']} #{card['number']} « {card['title']} » " f"— {card['days']} j en {card['column_label']}" ) if data["milestones_due"]: lines.append("\n📅 Milestones proches :") for ms in data["milestones_due"][:5]: lines.append( f" • {ms['repo']} « {ms['title']} » — échéance " f"{ms['due_on']} ({ms['open']} ouverte(s))" ) if data["focus_total"] == 0 and not data["aging"]: lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.") if data.get("standup"): lines.append("\n" + data["standup"]) return "\n".join(lines) async def send_ntfy(settings: Settings, text: str) -> bool: """POST the briefing to the configured ntfy topic. True on 2xx.""" if not ntfy_configured(settings): return False url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}" headers = { # ntfy headers must stay ASCII; the body carries the accents. "Title": "Kanbante - briefing du jour", "Tags": "clipboard,chart_with_upwards_trend", } if settings.ntfy_token: headers["Authorization"] = f"Bearer {settings.ntfy_token}" async with httpx.AsyncClient(timeout=15.0) as client: response = await client.post(url, content=text.encode("utf-8"), headers=headers) return response.status_code < 300