diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py
index 5de10d8..db0cd4d 100644
--- a/src/glitchup_bot/bot/handlers/commands.py
+++ b/src/glitchup_bot/bot/handlers/commands.py
@@ -13,13 +13,12 @@ from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import (
admin_home_keyboard,
admin_overview_keyboard,
+ admin_recipient_group_keyboard,
+ admin_recipients_keyboard,
admin_result_keyboard,
admin_sync_keyboard,
help_home_keyboard,
- help_monitoring_keyboard,
- help_releases_keyboard,
help_result_keyboard,
- help_subscriptions_keyboard,
)
from glitchup_bot.services.admins import (
add_admin,
@@ -30,8 +29,6 @@ from glitchup_bot.services.admins import (
from glitchup_bot.services.digest_builder import (
build_digest,
build_project_summary,
- build_release_detail,
- build_release_summary,
build_stale_issues,
build_sync_status,
build_today_summary,
@@ -52,12 +49,14 @@ from glitchup_bot.services.routing import (
set_project_group,
set_topic_override,
)
+from glitchup_bot.services.sync_service import get_last_sync_state
router = Router()
logger = logging.getLogger(__name__)
MAX_PAGE_CHARS = 3000
MAX_PAGE_LINES = 18
MAX_PAGINATION_SESSIONS = 200
+PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {}
@dataclass(slots=True)
@@ -148,97 +147,46 @@ def _result_keyboard(
def _help_text(is_admin: bool) -> str:
+ return "\n".join(
+ [
+ "GlitchUp Bot",
+ "",
+ "Выберите нужную сводку кнопками ниже.",
+ "Пользователю доступны только базовые экраны.",
+ "",
+ "• Сегодня",
+ "• Неделя",
+ "• Самые шумные",
+ "• Давно висят",
+ *(["", "• Для управления откройте админ-панель"] if is_admin else []),
+ ]
+ )
+
+
+async def _start_text(is_admin: bool) -> str:
+ state = await get_last_sync_state("api_sync")
+ sync_value = (
+ state.last_successful_at.astimezone().strftime("%Y-%m-%d %H:%M")
+ if state and state.last_successful_at
+ else "ещё не было"
+ )
lines = [
"GlitchUp Bot",
+ f"Синхронизация: {escape(sync_value)}",
"",
- "Понятное меню для просмотра ошибок, релизов и подписок.",
- "",
- "Что можно сделать:",
- "• открыть сводку за неделю или за сегодня",
- "• посмотреть топ самых шумных и старых issues",
- "• проверить последние релизы",
- "• включить или отключить личные уведомления",
- "• узнать статус последней синхронизации",
- "",
- "Быстрые команды:",
- "• /week, /today, /top, /stale",
- "• /releases, /release <version>",
- "• /project <slug>",
- "• /subscribe backend|frontend",
- "• /unsubscribe backend|frontend",
+ "Откройте нужную сводку кнопками ниже.",
]
-
if is_admin:
- lines.extend(
- [
- "",
- "Для админа:",
- "• /admin — панель управления",
- "• /sync — принудительная синхронизация",
- "• /admins — список администраторов",
- "• /admin_add <user_id> или reply на сообщение",
- "• /admin_del <user_id>",
- "• /ownership — текущее распределение routing-настроек",
- "• /mute_list — список mute rules",
- ]
- )
-
+ lines.extend(["", "Администрирование доступно через кнопку ниже."])
return "\n".join(lines)
-def _help_monitoring_text() -> str:
+def _admin_overview_text() -> str:
return "\n".join(
[
- "Обзор и мониторинг",
+ "Сводки",
"",
- "Здесь собраны основные срезы по состоянию проектов.",
- "",
- "• Сводка за неделю — общая картина по новым issues и regressions",
- "• Сегодня — свежие проблемы за текущий день",
- "• Топ issues — самые шумные ошибки по числу событий",
- "• Старые issues — незакрытые проблемы, которые давно висят",
- ]
- )
-
-
-def _help_releases_text() -> str:
- return "\n".join(
- [
- "Релизы и версии",
- "",
- "Раздел помогает быстро понять, после каких релизов появились незакрытые issues.",
- "",
- "• Список релизов покажет версии, у которых есть активные проблемы",
- "• Как открыть релиз подскажет, как перейти к деталям по конкретной версии",
- ]
- )
-
-
-def _help_release_guide_text() -> str:
- return "\n".join(
- [
- "Как смотреть детали релиза",
- "",
- "1. Открой Список релизов и скопируй нужную версию.",
- "2. Выполни команду /release <version>.",
- "3. Бот покажет issues, связанные именно с этим релизом.",
- "",
- "Пример: /release 2026.03.27",
- ]
- )
-
-
-def _help_subscriptions_text() -> str:
- return "\n".join(
- [
- "Подписки",
- "",
- "Здесь можно управлять личными уведомлениями в DM.",
- "",
- "• backend — проблемы серверной части",
- "• frontend — проблемы клиентских приложений и web",
- "",
- "Подписка добавляет вас в runtime-настройки без редактирования `.env`.",
+ "Быстрый доступ к основным экранам без лишних разделов.",
]
)
@@ -248,13 +196,12 @@ def _admin_text() -> str:
[
"Админ-панель GlitchUp Bot",
"",
- "Панель разделена на понятные зоны, чтобы не искать нужное действие в длинном списке.",
- "",
- "• Центр синхронизации — запуск sync и проверка статуса",
- "• Сводки и мониторинг — основные обзорные экраны",
- "• Администраторы — список и управление доступом",
- "• Ownership и topics — текущая маршрутизация",
- "• Mute rules — правила скрытия шумных событий",
+ "Здесь только практичные разделы:",
+ "• синхронизация",
+ "• основные сводки",
+ "• получатели по backend/frontend",
+ "• администраторы",
+ "• routing и mute rules",
]
)
@@ -262,20 +209,31 @@ def _admin_text() -> str:
def _admin_sync_text() -> str:
return "\n".join(
[
- "Центр синхронизации",
+ "Синхронизация",
"",
- "Отсюда удобно запускать ручной sync и смотреть, "
+ "Отсюда удобно запускать ручной sync и проверять, "
"когда данные обновлялись в последний раз.",
]
)
-def _admin_overview_text() -> str:
+def _admin_recipients_text() -> str:
return "\n".join(
[
- "Сводки и мониторинг",
+ "Получатели уведомлений",
"",
- "Быстрый доступ к основным обзорным экранам для ручной проверки состояния проектов.",
+ "Выберите группу и назначайте Telegram ID через кнопки.",
+ "Самоподписка пользователей отключена.",
+ ]
+ )
+
+
+def _recipient_group_text(group_name: str) -> str:
+ return "\n".join(
+ [
+ f"{escape(group_name.capitalize())} получатели",
+ "",
+ "Можно посмотреть список, добавить новый Telegram ID или удалить существующий.",
]
)
@@ -285,8 +243,7 @@ def _admin_guide_text() -> str:
[
"Подсказка по админке",
"",
- "Через кнопки удобно смотреть состояние системы, "
- "а точечные настройки меняются командами.",
+ "Почти всё вынесено в кнопки и короткие сценарии.",
"",
"• /admin_add 123456 — добавить администратора",
"• /admin_add ответом на сообщение — добавить автора сообщения",
@@ -344,6 +301,18 @@ def _extract_target_user_id(message: Message) -> int | None:
return None
+async def _recipients_text(group_name: str) -> str:
+ subscribers = await resolve_subscribers(group_name)
+ title = "Backend" if group_name == "backend" else "Frontend"
+ if not subscribers:
+ return f"{title} получатели\n\nСписок пуст."
+
+ lines = [f"{title} получатели", ""]
+ for user_id in subscribers:
+ lines.append(f"• {user_id}")
+ return "\n".join(lines)
+
+
async def _require_admin(message: Message) -> bool:
if await _is_admin_user(_sender_id(message)):
return True
@@ -594,7 +563,7 @@ async def _mute_rules_text() -> str:
async def cmd_start(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await message.answer(
- _help_text(is_admin),
+ await _start_text(is_admin),
reply_markup=help_home_keyboard(is_admin),
disable_web_page_preview=True,
)
@@ -636,100 +605,38 @@ async def cb_help_actions(callback: CallbackQuery) -> None:
reply_markup=help_home_keyboard(is_admin),
)
return
- if action == "menu:monitoring":
- await _show_callback_screen(
- callback,
- _help_monitoring_text(),
- reply_markup=help_monitoring_keyboard(is_admin),
- )
- return
- if action == "menu:releases":
- await _show_callback_screen(
- callback,
- _help_releases_text(),
- reply_markup=help_releases_keyboard(is_admin),
- )
- return
- if action == "menu:subscriptions":
- await _show_callback_screen(
- callback,
- _help_subscriptions_text(),
- reply_markup=help_subscriptions_keyboard(is_admin),
- )
- return
- if action == "release_guide":
- await _show_callback_screen(
- callback,
- _help_release_guide_text(),
- reply_markup=help_result_keyboard("help:menu:releases", is_admin),
- )
- return
if action == "week":
await _run_summary_action(
callback,
- lambda: build_digest(refresh=True),
- back_callback="help:menu:monitoring",
+ lambda: build_digest(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
return
if action == "today":
await _run_summary_action(
callback,
- lambda: build_today_summary(refresh=True),
- back_callback="help:menu:monitoring",
+ lambda: build_today_summary(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
return
if action == "top":
await _run_summary_action(
callback,
- lambda: build_top_issues(refresh=True),
- back_callback="help:menu:monitoring",
+ lambda: build_top_issues(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
return
if action == "stale":
await _run_summary_action(
callback,
- lambda: build_stale_issues(refresh=True),
- back_callback="help:menu:monitoring",
- is_admin=is_admin,
- )
- return
- if action == "releases":
- await _run_summary_action(
- callback,
- lambda: build_release_summary(refresh=True),
- back_callback="help:menu:releases",
- is_admin=is_admin,
- )
- return
- if action == "sync_status":
- await _run_summary_action(
- callback,
- build_sync_status,
+ lambda: build_stale_issues(refresh=False),
back_callback="help:open",
is_admin=is_admin,
)
return
- if action.startswith("sub:"):
- await _handle_subscription_action(
- callback,
- action.split(":", 1)[1],
- "subscribe",
- _callback_sender_id(callback),
- reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
- )
- return
- if action.startswith("unsub:"):
- await _handle_subscription_action(
- callback,
- action.split(":", 1)[1],
- "unsubscribe",
- _callback_sender_id(callback),
- reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
- )
- return
@router.callback_query(F.data == "admin:open")
@@ -767,6 +674,65 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
reply_markup=admin_overview_keyboard(),
)
return
+ if action == "menu:recipients":
+ await _show_callback_screen(
+ callback,
+ _admin_recipients_text(),
+ reply_markup=admin_recipients_keyboard(),
+ )
+ return
+ if action in {"recipients:backend", "recipients:frontend"}:
+ group_name = action.split(":", 1)[1]
+ await _show_callback_screen(
+ callback,
+ _recipient_group_text(group_name),
+ reply_markup=admin_recipient_group_keyboard(group_name),
+ )
+ return
+ if action.startswith("recipients:list:"):
+ group_name = action.rsplit(":", 1)[1]
+ await _deliver_result(
+ callback,
+ await _recipients_text(group_name),
+ back_callback=f"admin:recipients:{group_name}",
+ is_admin=True,
+ admin_mode=True,
+ )
+ return
+ if action.startswith("recipients:add:"):
+ group_name = action.rsplit(":", 1)[1]
+ admin_id = _callback_sender_id(callback)
+ if admin_id is not None:
+ PENDING_RECIPIENT_ACTIONS[admin_id] = ("add", group_name)
+ await _show_callback_screen(
+ callback,
+ "\n".join(
+ [
+ f"{escape(group_name.capitalize())}",
+ "",
+ "Отправьте следующим сообщением Telegram ID, который нужно добавить.",
+ ]
+ ),
+ reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"),
+ )
+ return
+ if action.startswith("recipients:del:"):
+ group_name = action.rsplit(":", 1)[1]
+ admin_id = _callback_sender_id(callback)
+ if admin_id is not None:
+ PENDING_RECIPIENT_ACTIONS[admin_id] = ("del", group_name)
+ await _show_callback_screen(
+ callback,
+ "\n".join(
+ [
+ f"{escape(group_name.capitalize())}",
+ "",
+ "Отправьте следующим сообщением Telegram ID, который нужно удалить.",
+ ]
+ ),
+ reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"),
+ )
+ return
if action == "admins":
await _deliver_result(
callback,
@@ -813,19 +779,10 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
admin_mode=True,
)
return
- if action == "releases":
- await _run_summary_action(
- callback,
- lambda: build_release_summary(refresh=True),
- back_callback="admin:menu:overview",
- is_admin=True,
- admin_mode=True,
- )
- return
if action == "today":
await _run_summary_action(
callback,
- lambda: build_today_summary(refresh=True),
+ lambda: build_today_summary(refresh=False),
back_callback="admin:menu:overview",
is_admin=True,
admin_mode=True,
@@ -834,7 +791,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "week":
await _run_summary_action(
callback,
- lambda: build_digest(refresh=True),
+ lambda: build_digest(refresh=False),
back_callback="admin:menu:overview",
is_admin=True,
admin_mode=True,
@@ -843,7 +800,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "top":
await _run_summary_action(
callback,
- lambda: build_top_issues(refresh=True),
+ lambda: build_top_issues(refresh=False),
back_callback="admin:menu:overview",
is_admin=True,
admin_mode=True,
@@ -852,7 +809,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "stale":
await _run_summary_action(
callback,
- lambda: build_stale_issues(refresh=True),
+ lambda: build_stale_issues(refresh=False),
back_callback="admin:menu:overview",
is_admin=True,
admin_mode=True,
@@ -916,8 +873,8 @@ async def cmd_week(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
- await build_digest(refresh=True),
- back_callback="help:menu:monitoring",
+ await build_digest(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
@@ -927,8 +884,8 @@ async def cmd_today(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
- await build_today_summary(refresh=True),
- back_callback="help:menu:monitoring",
+ await build_today_summary(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
@@ -943,8 +900,8 @@ async def cmd_project(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
- await build_project_summary(args[1].strip(), refresh=True),
- back_callback="help:menu:monitoring",
+ await build_project_summary(args[1].strip(), refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
@@ -954,8 +911,8 @@ async def cmd_top(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
- await build_top_issues(refresh=True),
- back_callback="help:menu:monitoring",
+ await build_top_issues(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
@@ -965,37 +922,20 @@ async def cmd_stale(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
- await build_stale_issues(refresh=True),
- back_callback="help:menu:monitoring",
+ await build_stale_issues(refresh=False),
+ back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("releases"))
async def cmd_releases(message: Message) -> None:
- is_admin = await _is_admin_user(_sender_id(message))
- await _deliver_result(
- message,
- await build_release_summary(refresh=True),
- back_callback="help:menu:releases",
- is_admin=is_admin,
- )
+ await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
@router.message(Command("release"))
async def cmd_release(message: Message) -> None:
- args = message.text.split(maxsplit=1) if message.text else []
- if len(args) < 2:
- await message.answer("Использование: /release <version>")
- return
-
- is_admin = await _is_admin_user(_sender_id(message))
- await _deliver_result(
- message,
- await build_release_detail(args[1].strip(), refresh=True),
- back_callback="help:menu:releases",
- is_admin=is_admin,
- )
+ await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
@router.message(Command("sync_status"))
@@ -1090,38 +1030,57 @@ async def cmd_admin_del(message: Message) -> None:
)
-@router.message(Command("subscribe"))
-async def cmd_subscribe(message: Message) -> None:
- args = message.text.split(maxsplit=1) if message.text else []
- if len(args) < 2:
- await message.answer("Использование: /subscribe <backend|frontend>")
+@router.message(F.text)
+async def cmd_pending_recipient_input(message: Message) -> None:
+ user_id = _sender_id(message)
+ if user_id is None or user_id not in PENDING_RECIPIENT_ACTIONS:
+ return
+ if not await _require_admin(message):
return
- is_admin = await _is_admin_user(_sender_id(message))
- await _handle_subscription_action(
+ raw_value = (message.text or "").strip()
+ if raw_value.startswith("/"):
+ return
+ if not raw_value.lstrip("-").isdigit():
+ await message.answer("Нужен числовой Telegram ID.")
+ return
+
+ action, group_name = PENDING_RECIPIENT_ACTIONS.pop(user_id)
+ target_id = int(raw_value)
+
+ if action == "add":
+ await add_subscriber(group_name, target_id)
+ text = (
+ f"Telegram ID {target_id} добавлен в группу "
+ f"{escape(group_name)}."
+ )
+ else:
+ removed = await remove_subscriber(group_name, target_id)
+ text = (
+ f"Telegram ID {target_id} удалён из группы "
+ f"{escape(group_name)}."
+ if removed
+ else f"Telegram ID {target_id} не найден в группе "
+ f"{escape(group_name)}."
+ )
+
+ await _deliver_result(
message,
- args[1].strip().lower(),
- "subscribe",
- _sender_id(message),
- reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
+ text,
+ back_callback=f"admin:recipients:{group_name}",
+ is_admin=True,
+ admin_mode=True,
)
+@router.message(Command("subscribe"))
+async def cmd_subscribe(message: Message) -> None:
+ await message.answer("Самоподписка отключена. Получателей настраивает администратор.")
+
+
@router.message(Command("unsubscribe"))
async def cmd_unsubscribe(message: Message) -> None:
- args = message.text.split(maxsplit=1) if message.text else []
- if len(args) < 2:
- await message.answer("Использование: /unsubscribe <backend|frontend>")
- return
-
- is_admin = await _is_admin_user(_sender_id(message))
- await _handle_subscription_action(
- message,
- args[1].strip().lower(),
- "unsubscribe",
- _sender_id(message),
- reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
- )
+ await message.answer("Самоподписка отключена. Получателей настраивает администратор.")
@router.message(Command("ownership"))
diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py
index de844f9..878f0c2 100644
--- a/src/glitchup_bot/bot/keyboards.py
+++ b/src/glitchup_bot/bot/keyboards.py
@@ -24,50 +24,15 @@ def _add_pagination_row(
def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
- builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring")
- builder.button(text="Релизы и версии", callback_data="help:menu:releases")
- builder.button(text="Подписки", callback_data="help:menu:subscriptions")
- builder.button(text="Статус синхронизации", callback_data="help:sync_status")
- if is_admin:
- builder.button(text="Админ-панель", callback_data="admin:open")
- builder.adjust(2, 2, 1)
- return builder.as_markup()
-
-
-def help_monitoring_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
- builder.button(text="Сводка за неделю", callback_data="help:week")
builder.button(text="Сегодня", callback_data="help:today")
- builder.button(text="Топ issues", callback_data="help:top")
- builder.button(text="Старые issues", callback_data="help:stale")
- builder.button(text="Назад", callback_data="help:open")
+ builder.button(text="Неделя", callback_data="help:week")
+ builder.button(text="Самые шумные", callback_data="help:top")
+ builder.button(text="Давно висят", callback_data="help:stale")
if is_admin:
builder.button(text="Админ-панель", callback_data="admin:open")
- builder.adjust(2, 2, 1, 1)
- return builder.as_markup()
-
-
-def help_releases_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
- builder.button(text="Список релизов", callback_data="help:releases")
- builder.button(text="Как открыть релиз", callback_data="help:release_guide")
- builder.button(text="Назад", callback_data="help:open")
- if is_admin:
- builder.button(text="Админ-панель", callback_data="admin:open")
- builder.adjust(2, 1, 1)
- return builder.as_markup()
-
-
-def help_subscriptions_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
- builder = InlineKeyboardBuilder()
- builder.button(text="Подписаться на backend", callback_data="help:sub:backend")
- builder.button(text="Подписаться на frontend", callback_data="help:sub:frontend")
- builder.button(text="Отписаться от backend", callback_data="help:unsub:backend")
- builder.button(text="Отписаться от frontend", callback_data="help:unsub:frontend")
- builder.button(text="Назад", callback_data="help:open")
- if is_admin:
- builder.button(text="Админ-панель", callback_data="admin:open")
- builder.adjust(2, 2, 1, 1)
+ builder.adjust(2, 2, 1)
+ else:
+ builder.adjust(2, 2)
return builder.as_markup()
@@ -104,14 +69,15 @@ def help_result_keyboard(
def admin_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
- builder.button(text="Центр синхронизации", callback_data="admin:menu:sync")
- builder.button(text="Сводки и мониторинг", callback_data="admin:menu:overview")
+ builder.button(text="Сводки", callback_data="admin:menu:overview")
+ builder.button(text="Синхронизация", callback_data="admin:menu:sync")
+ builder.button(text="Получатели", callback_data="admin:menu:recipients")
builder.button(text="Администраторы", callback_data="admin:admins")
- builder.button(text="Ownership и topics", callback_data="admin:ownership")
+ builder.button(text="Routing и topics", callback_data="admin:ownership")
builder.button(text="Mute rules", callback_data="admin:mute_list")
builder.button(text="Инструкция", callback_data="admin:guide")
builder.button(text="Пользовательское меню", callback_data="help:open")
- builder.adjust(2, 2, 2, 1)
+ builder.adjust(2, 2, 2, 1, 1)
return builder.as_markup()
@@ -127,13 +93,31 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup:
def admin_overview_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
- builder.button(text="Сводка за неделю", callback_data="admin:week")
builder.button(text="Сегодня", callback_data="admin:today")
- builder.button(text="Топ issues", callback_data="admin:top")
- builder.button(text="Старые issues", callback_data="admin:stale")
- builder.button(text="Релизы", callback_data="admin:releases")
+ builder.button(text="Неделя", callback_data="admin:week")
+ builder.button(text="Самые шумные", callback_data="admin:top")
+ builder.button(text="Давно висят", callback_data="admin:stale")
builder.button(text="Назад", callback_data="admin:open")
- builder.adjust(2, 2, 1, 1)
+ builder.adjust(2, 2, 1)
+ return builder.as_markup()
+
+
+def admin_recipients_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="Backend", callback_data="admin:recipients:backend")
+ builder.button(text="Frontend", callback_data="admin:recipients:frontend")
+ builder.button(text="Назад", callback_data="admin:open")
+ builder.adjust(2, 1)
+ return builder.as_markup()
+
+
+def admin_recipient_group_keyboard(group_name: str) -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="Список", callback_data=f"admin:recipients:list:{group_name}")
+ builder.button(text="Добавить ID", callback_data=f"admin:recipients:add:{group_name}")
+ builder.button(text="Удалить ID", callback_data=f"admin:recipients:del:{group_name}")
+ builder.button(text="Назад", callback_data="admin:menu:recipients")
+ builder.adjust(2, 1, 1)
return builder.as_markup()
diff --git a/src/glitchup_bot/main.py b/src/glitchup_bot/main.py
index f3288c9..5c03f2b 100644
--- a/src/glitchup_bot/main.py
+++ b/src/glitchup_bot/main.py
@@ -8,6 +8,7 @@ from glitchup_bot.bot.bot import close_bot, get_bot, get_dispatcher
from glitchup_bot.config import settings
from glitchup_bot.glitchtip_client.client import close_glitchtip_client
from glitchup_bot.models.database import dispose_engine
+from glitchup_bot.services.sync_service import warm_issue_cache_on_startup
from glitchup_bot.tasks.scheduler import setup_scheduler, shutdown_scheduler
logging.basicConfig(
@@ -37,6 +38,7 @@ async def shutdown_resources() -> None:
async def main() -> None:
logger.info("GlitchUp Bot starting")
+ await warm_issue_cache_on_startup()
setup_scheduler()
api_task = asyncio.create_task(start_api(), name="api")
diff --git a/src/glitchup_bot/services/digest_builder.py b/src/glitchup_bot/services/digest_builder.py
index 979a788..e0b4d97 100644
--- a/src/glitchup_bot/services/digest_builder.py
+++ b/src/glitchup_bot/services/digest_builder.py
@@ -1,4 +1,6 @@
+import re
from collections import defaultdict
+from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from html import escape
@@ -11,12 +13,96 @@ from glitchup_bot.services.sync_service import (
)
-def _issue_label(issue: IssueSnapshot) -> str:
- title = escape(issue.title)
- slug = escape(issue.project_slug)
- if issue.link:
- return f'{title} ({slug})'
- return f"{title} ({slug})"
+@dataclass(slots=True)
+class AggregatedIssue:
+ project_slug: str
+ signature: str
+ title: str
+ link: str | None
+ first_seen: datetime | None
+ last_seen: datetime | None
+ event_count: int
+ occurrences: int
+ is_regression: bool
+ release: str | None
+
+
+_TIMESTAMP_PREFIX_RE = re.compile(
+ r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\|\s+[^|]+\|\s+[^-]+-\s+"
+)
+
+
+def _clean_issue_title(title: str) -> str:
+ cleaned = _TIMESTAMP_PREFIX_RE.sub("", title).strip()
+ if cleaned:
+ return cleaned
+ if " - " in title:
+ tail = title.split(" - ", 1)[1].strip()
+ if tail:
+ return tail
+ return title.strip() or "unknown"
+
+
+def _issue_label(title: str, project_slug: str, link: str | None) -> str:
+ safe_title = escape(title)
+ safe_slug = escape(project_slug)
+ if link:
+ return f'{safe_title} ({safe_slug})'
+ return f"{safe_title} ({safe_slug})"
+
+
+def _format_issue_line(issue: AggregatedIssue, *, include_seen: bool = False) -> str:
+ parts = [f"• {issue.event_count} событий"]
+ if issue.occurrences > 1:
+ parts.append(f"{issue.occurrences} повторов")
+ parts.append(_issue_label(issue.title, issue.project_slug, issue.link))
+ line = " — ".join([parts[0], ", ".join(parts[1:])])
+ if include_seen and issue.last_seen:
+ return f"{line}\nпоследнее: {escape(issue.last_seen.strftime('%Y-%m-%d %H:%M'))}"
+ return line
+
+
+def _aggregate_issues(issues: list[IssueSnapshot]) -> list[AggregatedIssue]:
+ grouped: dict[tuple[str, str], AggregatedIssue] = {}
+
+ for issue in issues:
+ cleaned_title = _clean_issue_title(issue.title)
+ signature = (issue.culprit or cleaned_title).strip().lower()
+ key = (issue.project_slug, signature)
+ current = grouped.get(key)
+
+ if current is None:
+ grouped[key] = AggregatedIssue(
+ project_slug=issue.project_slug,
+ signature=signature,
+ title=cleaned_title,
+ link=issue.link,
+ first_seen=issue.first_seen,
+ last_seen=issue.last_seen,
+ event_count=issue.event_count,
+ occurrences=1,
+ is_regression=issue.is_regression,
+ release=issue.release,
+ )
+ continue
+
+ current.event_count += issue.event_count
+ current.occurrences += 1
+ current.is_regression = current.is_regression or issue.is_regression
+ if issue.first_seen and (
+ current.first_seen is None or issue.first_seen < current.first_seen
+ ):
+ current.first_seen = issue.first_seen
+ if issue.last_seen and (current.last_seen is None or issue.last_seen > current.last_seen):
+ current.last_seen = issue.last_seen
+ if issue.link:
+ current.link = issue.link
+ if not current.link and issue.link:
+ current.link = issue.link
+ if not current.release and issue.release:
+ current.release = issue.release
+
+ return list(grouped.values())
async def _load_issues(
@@ -35,41 +121,32 @@ async def _load_issues(
async def build_digest(refresh: bool = True) -> str:
now = datetime.now(UTC)
week_ago = now - timedelta(days=7)
- issues = await _load_issues(refresh=refresh)
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
- new_issues: list[IssueSnapshot] = []
- regressions: list[IssueSnapshot] = []
- stale: list[IssueSnapshot] = []
- by_release: dict[str, list[IssueSnapshot]] = defaultdict(list)
+ new_issues = [
+ issue for issue in aggregated if issue.first_seen and issue.first_seen >= week_ago
+ ]
+ regressions = [issue for issue in aggregated if issue.is_regression]
+ stale = [issue for issue in aggregated if issue.first_seen and issue.first_seen < week_ago]
project_stats: dict[str, dict[str, int]] = defaultdict(
- lambda: {"new": 0, "regression": 0, "events": 0}
+ lambda: {"issues": 0, "regression": 0, "events": 0}
)
- top_noisy = sorted(issues, key=lambda item: item.event_count, reverse=True)
+ top_noisy = sorted(aggregated, key=lambda item: item.event_count, reverse=True)
- for issue in issues:
+ for issue in aggregated:
if issue.first_seen and issue.first_seen >= week_ago:
- new_issues.append(issue)
- project_stats[issue.project_slug]["new"] += 1
-
+ project_stats[issue.project_slug]["issues"] += 1
if issue.is_regression:
- regressions.append(issue)
project_stats[issue.project_slug]["regression"] += 1
-
- if issue.first_seen and issue.first_seen < week_ago:
- stale.append(issue)
-
- if issue.release:
- by_release[issue.release].append(issue)
-
project_stats[issue.project_slug]["events"] += issue.event_count
lines = [
- "📊 GlitchTip digest за неделю",
+ "📊 Сводка за неделю",
"",
"Всего:",
- f"• новых issues: {len(new_issues)}",
+ f"• новых групп проблем: {len(new_issues)}",
f"• regressions: {len(regressions)}",
- f"• unresolved > 7 дней: {len(stale)}",
+ f"• старых групп > 7 дней: {len(stale)}",
"",
]
@@ -77,47 +154,35 @@ async def build_digest(refresh: bool = True) -> str:
lines.append("По проектам:")
for slug, stats in sorted(
project_stats.items(),
- key=lambda item: (item[1]["new"], item[1]["regression"], item[0]),
- reverse=True,
- )[:5]:
- parts = [f"{stats['new']} новых"]
- if stats["regression"]:
- parts.append(f"{stats['regression']} regression")
- lines.append(f"• {escape(slug)} — {', '.join(parts)}")
- lines.append("")
-
- if by_release:
- lines.append("После релизов:")
- for release_name, release_issues in sorted(
- by_release.items(),
- key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])),
+ key=lambda item: (item[1]["issues"], item[1]["events"], item[0]),
reverse=True,
)[:5]:
lines.append(
- f"• {escape(release_name)} — "
- f"{len(release_issues)} issues, "
- f"{sum(issue.event_count for issue in release_issues)} событий"
+ f"• {escape(slug)} — {stats['issues']} групп, {stats['events']} событий"
)
lines.append("")
if top_noisy:
- lines.append("Топ шумных:")
+ lines.append("Самые шумные:")
for issue in top_noisy[:5]:
- lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий")
+ lines.append(_format_issue_line(issue))
lines.append("")
if stale:
- lines.append("Хвосты:")
+ lines.append("Давно висят:")
for issue in sorted(
stale,
key=lambda item: (now - (item.first_seen or now)).days,
reverse=True,
)[:5]:
age = (now - (issue.first_seen or now)).days
- lines.append(f"• {_issue_label(issue)} — {age} дн. без разбора")
+ lines.append(
+ f"• {age} дн. — "
+ f"{_issue_label(issue.title, issue.project_slug, issue.link)}"
+ )
if len(lines) == 7:
- lines.append("Все чисто! Новых проблем нет.")
+ lines.append("Все спокойно. Новых проблем нет.")
return "\n".join(lines)
@@ -125,36 +190,41 @@ async def build_digest(refresh: bool = True) -> str:
async def build_today_summary(refresh: bool = True) -> str:
now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
- issues = await _load_issues(refresh=refresh)
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
today_issues = [
- issue for issue in issues if issue.first_seen and issue.first_seen >= today_start
+ issue for issue in aggregated if issue.first_seen and issue.first_seen >= today_start
]
if not today_issues:
- return "За сегодня новых issues не обнаружено."
+ return "За сегодня новых проблем не обнаружено."
- lines = [f"📋 Сегодня: {len(today_issues)} новых issues", ""]
+ total_events = sum(issue.event_count for issue in today_issues)
+ lines = [
+ f"📋 Сегодня: {len(today_issues)} групп проблем",
+ f"• событий за сегодня: {total_events}",
+ "",
+ ]
for issue in sorted(
today_issues,
- key=lambda item: item.first_seen or now,
+ key=lambda item: (item.event_count, item.last_seen or now),
reverse=True,
)[:10]:
- lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий")
+ lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines)
async def build_project_summary(project_slug: str, refresh: bool = True) -> str:
- issues = await _load_issues([project_slug], refresh=refresh)
- if not issues:
- return f"{escape(project_slug)}: нет unresolved issues."
+ aggregated = _aggregate_issues(await _load_issues([project_slug], refresh=refresh))
+ if not aggregated:
+ return f"{escape(project_slug)}: нет активных проблем."
- total_events = sum(issue.event_count for issue in issues)
- regressions = [issue for issue in issues if issue.is_regression]
+ total_events = sum(issue.event_count for issue in aggregated)
+ regressions = [issue for issue in aggregated if issue.is_regression]
lines = [
f"📦 {escape(project_slug)}",
"",
- f"• unresolved issues: {len(issues)}",
+ f"• групп проблем: {len(aggregated)}",
f"• всего событий: {total_events}",
f"• regressions: {len(regressions)}",
"",
@@ -162,23 +232,23 @@ async def build_project_summary(project_slug: str, refresh: bool = True) -> str:
]
for issue in sorted(
- issues,
+ aggregated,
key=lambda item: item.last_seen or datetime.min.replace(tzinfo=UTC),
reverse=True,
)[:5]:
- lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий")
+ lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines)
async def build_top_issues(limit: int = 10, refresh: bool = True) -> str:
- issues = await _load_issues(refresh=refresh)
- if not issues:
- return "Нет unresolved issues."
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
+ if not aggregated:
+ return "Нет активных проблем."
- lines = ["🔊 Топ шумных issues", ""]
- for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]:
- lines.append(f"• {issue.event_count} событий — {_issue_label(issue)}")
+ lines = ["🔊 Самые шумные проблемы", ""]
+ for issue in sorted(aggregated, key=lambda item: item.event_count, reverse=True)[:limit]:
+ lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines)
@@ -189,44 +259,48 @@ async def build_stale_issues(
refresh: bool = True,
) -> str:
now = datetime.now(UTC)
- issues = await _load_issues(refresh=refresh)
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
stale = [
- issue for issue in issues if issue.first_seen and (now - issue.first_seen).days >= min_days
+ issue
+ for issue in aggregated
+ if issue.first_seen and (now - issue.first_seen).days >= min_days
]
if not stale:
- return "Нет старых незакрытых issues (> 7 дней)."
+ return "Нет старых незакрытых проблем."
- lines = ["🕸 Старые незакрытые issues", ""]
+ lines = ["🕸 Старые незакрытые проблемы", ""]
for issue in sorted(
stale,
key=lambda item: now - (item.first_seen or now),
reverse=True,
)[:limit]:
age = (now - (issue.first_seen or now)).days
- lines.append(f"• {age} дн. — {_issue_label(issue)} ({issue.event_count} событий)")
+ lines.append(
+ f"• {age} дн. — {_issue_label(issue.title, issue.project_slug, issue.link)}"
+ )
return "\n".join(lines)
async def build_release_summary(limit: int = 10, refresh: bool = True) -> str:
- issues = await _load_issues(refresh=refresh)
- grouped: dict[str, list[IssueSnapshot]] = defaultdict(list)
- for issue in issues:
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
+ grouped: dict[str, list[AggregatedIssue]] = defaultdict(list)
+ for issue in aggregated:
if issue.release:
grouped[issue.release].append(issue)
if not grouped:
return "Релизы в данных GlitchTip не обнаружены."
- lines = ["🚀 Релизы с незакрытыми issue", ""]
+ lines = ["🚀 Релизы с проблемами", ""]
for release_name, release_issues in sorted(
grouped.items(),
key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])),
reverse=True,
)[:limit]:
lines.append(
- f"• {escape(release_name)} — {len(release_issues)} issues, "
+ f"• {escape(release_name)} — {len(release_issues)} групп, "
f"{sum(issue.event_count for issue in release_issues)} событий"
)
@@ -234,15 +308,14 @@ async def build_release_summary(limit: int = 10, refresh: bool = True) -> str:
async def build_release_detail(release_name: str, refresh: bool = True) -> str:
- issues = await _load_issues(refresh=refresh)
- matched = [issue for issue in issues if issue.release == release_name]
+ aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
+ matched = [issue for issue in aggregated if issue.release == release_name]
if not matched:
- return f"Для релиза {escape(release_name)} незакрытых issues не найдено."
+ return f"Для релиза {escape(release_name)} активных проблем не найдено."
lines = [f"🚀 Релиз {escape(release_name)}", ""]
for issue in sorted(matched, key=lambda item: item.event_count, reverse=True)[:10]:
- suffix = " regression" if issue.is_regression else ""
- lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий{suffix}")
+ lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines)
diff --git a/src/glitchup_bot/services/sync_service.py b/src/glitchup_bot/services/sync_service.py
index 4c2d761..7839d2c 100644
--- a/src/glitchup_bot/services/sync_service.py
+++ b/src/glitchup_bot/services/sync_service.py
@@ -1,4 +1,5 @@
import logging
+from asyncio import Lock, wait_for
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -13,6 +14,7 @@ from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.sync import SyncState
logger = logging.getLogger(__name__)
+_sync_lock = Lock()
@dataclass(slots=True)
@@ -106,86 +108,109 @@ async def mark_sync_success(source: str) -> None:
async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary:
- slugs = project_slugs or _configured_project_slugs()
- client = get_glitchtip_client()
- snapshots: list[IssueSnapshot] = []
+ async with _sync_lock:
+ slugs = project_slugs or _configured_project_slugs()
+ client = get_glitchtip_client()
+ snapshots: list[IssueSnapshot] = []
- for slug in slugs:
- issues = await client.list_issues(slug)
- snapshots.extend(
- _normalize_issue(slug, issue) for issue in issues if issue.get("id") is not None
+ for slug in slugs:
+ issues = await client.list_issues(slug)
+ snapshots.extend(
+ _normalize_issue(slug, issue) for issue in issues if issue.get("id") is not None
+ )
+
+ issue_ids_by_slug: dict[str, set[int]] = defaultdict(set)
+ for snapshot in snapshots:
+ issue_ids_by_slug[snapshot.project_slug].add(snapshot.issue_id)
+
+ now = datetime.now(UTC)
+ resolved_count = 0
+
+ async with get_session_factory()() as session:
+ existing_rows = (
+ await session.execute(select(IssueCache).where(IssueCache.project_slug.in_(slugs)))
+ ).scalars()
+ existing_by_id = {row.glitchtip_issue_id: row for row in existing_rows}
+
+ for snapshot in snapshots:
+ row = existing_by_id.get(snapshot.issue_id)
+ if row is None:
+ row = IssueCache(
+ glitchtip_issue_id=snapshot.issue_id,
+ project_slug=snapshot.project_slug,
+ title=snapshot.title,
+ culprit=snapshot.culprit,
+ level=snapshot.level,
+ status=snapshot.status,
+ first_seen=snapshot.first_seen,
+ last_seen=snapshot.last_seen,
+ event_count=snapshot.event_count,
+ is_regression=snapshot.is_regression,
+ link=snapshot.link,
+ release=snapshot.release,
+ )
+ session.add(row)
+ continue
+
+ row.project_slug = snapshot.project_slug
+ row.title = snapshot.title
+ row.culprit = snapshot.culprit
+ row.level = snapshot.level
+ row.status = snapshot.status
+ row.first_seen = snapshot.first_seen
+ row.last_seen = snapshot.last_seen
+ row.event_count = snapshot.event_count
+ row.is_regression = snapshot.is_regression
+ row.link = snapshot.link
+ row.release = snapshot.release
+ row.updated_at = now
+
+ for row in existing_by_id.values():
+ if row.glitchtip_issue_id in issue_ids_by_slug[row.project_slug]:
+ continue
+ if row.status != "resolved":
+ row.status = "resolved"
+ row.updated_at = now
+ resolved_count += 1
+
+ result = await session.execute(select(SyncState).where(SyncState.source == "api_sync"))
+ state = result.scalar_one_or_none()
+ if state is None:
+ state = SyncState(source="api_sync", last_successful_at=now)
+ session.add(state)
+ else:
+ state.last_successful_at = now
+
+ await session.commit()
+
+ return SyncSummary(
+ project_count=len(slugs),
+ issue_count=len(snapshots),
+ resolved_count=resolved_count,
+ synced_at=now,
)
- issue_ids_by_slug: dict[str, set[int]] = defaultdict(set)
- for snapshot in snapshots:
- issue_ids_by_slug[snapshot.project_slug].add(snapshot.issue_id)
- now = datetime.now(UTC)
- resolved_count = 0
-
- async with get_session_factory()() as session:
- existing_rows = (
- await session.execute(select(IssueCache).where(IssueCache.project_slug.in_(slugs)))
- ).scalars()
- existing_by_id = {row.glitchtip_issue_id: row for row in existing_rows}
-
- for snapshot in snapshots:
- row = existing_by_id.get(snapshot.issue_id)
- if row is None:
- row = IssueCache(
- glitchtip_issue_id=snapshot.issue_id,
- project_slug=snapshot.project_slug,
- title=snapshot.title,
- culprit=snapshot.culprit,
- level=snapshot.level,
- status=snapshot.status,
- first_seen=snapshot.first_seen,
- last_seen=snapshot.last_seen,
- event_count=snapshot.event_count,
- is_regression=snapshot.is_regression,
- link=snapshot.link,
- release=snapshot.release,
- )
- session.add(row)
- continue
-
- row.project_slug = snapshot.project_slug
- row.title = snapshot.title
- row.culprit = snapshot.culprit
- row.level = snapshot.level
- row.status = snapshot.status
- row.first_seen = snapshot.first_seen
- row.last_seen = snapshot.last_seen
- row.event_count = snapshot.event_count
- row.is_regression = snapshot.is_regression
- row.link = snapshot.link
- row.release = snapshot.release
- row.updated_at = now
-
- for row in existing_by_id.values():
- if row.glitchtip_issue_id in issue_ids_by_slug[row.project_slug]:
- continue
- if row.status != "resolved":
- row.status = "resolved"
- row.updated_at = now
- resolved_count += 1
-
- result = await session.execute(select(SyncState).where(SyncState.source == "api_sync"))
- state = result.scalar_one_or_none()
- if state is None:
- state = SyncState(source="api_sync", last_successful_at=now)
- session.add(state)
- else:
- state.last_successful_at = now
-
- await session.commit()
-
- return SyncSummary(
- project_count=len(slugs),
- issue_count=len(snapshots),
- resolved_count=resolved_count,
- synced_at=now,
- )
+async def warm_issue_cache_on_startup(timeout_seconds: int = 180) -> bool:
+ logger.info("Starting startup cache warmup")
+ try:
+ summary = await wait_for(sync_issues(), timeout=timeout_seconds)
+ logger.info(
+ "Startup cache warmup finished: %s projects, %s issues, %s resolved",
+ summary.project_count,
+ summary.issue_count,
+ summary.resolved_count,
+ )
+ return True
+ except TimeoutError:
+ logger.warning(
+ "Startup cache warmup timed out after %s seconds; continuing with cached data",
+ timeout_seconds,
+ )
+ return False
+ except Exception:
+ logger.exception("Startup cache warmup failed; continuing with cached data")
+ return False
async def load_issue_snapshots(
diff --git a/tests/test_digest_builder.py b/tests/test_digest_builder.py
index d73c1c9..5f0b122 100644
--- a/tests/test_digest_builder.py
+++ b/tests/test_digest_builder.py
@@ -46,12 +46,12 @@ async def test_build_digest_aggregates_projects(monkeypatch):
text = await digest_builder.build_digest()
- assert "новых issues: 1" in text
+ assert "новых групп проблем: 1" in text
assert "regressions: 1" in text
- assert "unresolved > 7 дней: 1" in text
+ assert "старых групп > 7 дней: 1" in text
assert "backend-production" in text
assert "Old frontend issue" in text
- assert "2026.03.20" in text
+ assert "12 событий" in text
@pytest.mark.asyncio
@@ -94,7 +94,8 @@ async def test_build_today_summary_limits_to_today(monkeypatch):
text = await digest_builder.build_today_summary()
- assert "Сегодня: 1 новых issues" in text
+ assert "Сегодня: 1 групп проблем" in text
+ assert "событий за сегодня: 2" in text
assert "Today issue" in text
assert "Old issue" not in text
@@ -127,7 +128,7 @@ async def test_build_project_summary(monkeypatch):
assert "backend-production" in text
assert "Project issue" in text
- assert "5 событий" in text
+ assert "всего событий: 5" in text
@pytest.mark.asyncio
@@ -219,9 +220,61 @@ async def test_build_release_summary_and_detail(monkeypatch):
detail = await digest_builder.build_release_detail("2026.03.27")
assert "2026.03.27" in summary
- assert "2 issues" in summary
+ assert "2 групп" in summary
assert "Release issue" in detail
+@pytest.mark.asyncio
+async def test_build_today_summary_groups_similar_titles(monkeypatch):
+ now = datetime.now(UTC)
+ issues = [
+ IssueSnapshot(
+ 1,
+ "backend-production",
+ (
+ "2026-03-30 10:59:52.605 | ERROR | logging:callHandlers:1762 - "
+ "The garbage collector is trying to clean up"
+ ),
+ None,
+ "error",
+ "unresolved",
+ now - timedelta(hours=2),
+ now - timedelta(minutes=5),
+ 1,
+ False,
+ None,
+ None,
+ ),
+ IssueSnapshot(
+ 2,
+ "backend-production",
+ (
+ "2026-03-30 09:59:26.932 | ERROR | logging:callHandlers:1762 - "
+ "The garbage collector is trying to clean up"
+ ),
+ None,
+ "error",
+ "unresolved",
+ now - timedelta(hours=1),
+ now,
+ 1,
+ False,
+ None,
+ None,
+ ),
+ ]
+
+ monkeypatch.setattr(
+ digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues)
+ )
+
+ text = await digest_builder.build_today_summary()
+
+ assert "Сегодня: 1 групп проблем" in text
+ assert "событий за сегодня: 2" in text
+ assert "2 повторов" in text
+ assert "The garbage collector is trying to clean up" in text
+
+
async def _async_value(value):
return value