format_standup_text + _standup_data (journal de la veille, focus du jour, vieillissantes QA/Révision); page /standup copiable, /api/standup; inclusion optionnelle au briefing ntfy (KANBANTE_BRIEFING_INCLUDE_STANDUP).
127 lines
4.9 KiB
Python
127 lines
4.9 KiB
Python
"""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
|