Упрощение для балбесов
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 35s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 18s

This commit is contained in:
2026-03-30 18:26:49 +07:00
parent bdae79db58
commit ea4a6fbe38
6 changed files with 548 additions and 452 deletions

View File

@@ -13,13 +13,12 @@ from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import ( from glitchup_bot.bot.keyboards import (
admin_home_keyboard, admin_home_keyboard,
admin_overview_keyboard, admin_overview_keyboard,
admin_recipient_group_keyboard,
admin_recipients_keyboard,
admin_result_keyboard, admin_result_keyboard,
admin_sync_keyboard, admin_sync_keyboard,
help_home_keyboard, help_home_keyboard,
help_monitoring_keyboard,
help_releases_keyboard,
help_result_keyboard, help_result_keyboard,
help_subscriptions_keyboard,
) )
from glitchup_bot.services.admins import ( from glitchup_bot.services.admins import (
add_admin, add_admin,
@@ -30,8 +29,6 @@ from glitchup_bot.services.admins import (
from glitchup_bot.services.digest_builder import ( from glitchup_bot.services.digest_builder import (
build_digest, build_digest,
build_project_summary, build_project_summary,
build_release_detail,
build_release_summary,
build_stale_issues, build_stale_issues,
build_sync_status, build_sync_status,
build_today_summary, build_today_summary,
@@ -52,12 +49,14 @@ from glitchup_bot.services.routing import (
set_project_group, set_project_group,
set_topic_override, set_topic_override,
) )
from glitchup_bot.services.sync_service import get_last_sync_state
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_PAGE_CHARS = 3000 MAX_PAGE_CHARS = 3000
MAX_PAGE_LINES = 18 MAX_PAGE_LINES = 18
MAX_PAGINATION_SESSIONS = 200 MAX_PAGINATION_SESSIONS = 200
PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {}
@dataclass(slots=True) @dataclass(slots=True)
@@ -148,97 +147,46 @@ def _result_keyboard(
def _help_text(is_admin: bool) -> str: def _help_text(is_admin: bool) -> str:
lines = [ return "\n".join(
[
"<b>GlitchUp Bot</b>", "<b>GlitchUp Bot</b>",
"", "",
"Понятное меню для просмотра ошибок, релизов и подписок.", "Выберите нужную сводку кнопками ниже.",
"Пользователю доступны только базовые экраны.",
"", "",
"<b>Что можно сделать:</b>", "• Сегодня",
"открыть сводку за неделю или за сегодня", "Неделя",
"посмотреть топ самых шумных и старых issues", "Самые шумные",
"проверить последние релизы", "Давно висят",
"• включить или отключить личные уведомления", *(["", "• Для управления откройте админ-панель"] if is_admin else []),
"• узнать статус последней синхронизации",
"",
"<b>Быстрые команды:</b>",
"• /week, /today, /top, /stale",
"• /releases, /release &lt;version&gt;",
"• /project &lt;slug&gt;",
"• /subscribe backend|frontend",
"• /unsubscribe backend|frontend",
]
if is_admin:
lines.extend(
[
"",
"<b>Для админа:</b>",
"• /admin — панель управления",
"• /sync — принудительная синхронизация",
"• /admins — список администраторов",
"• /admin_add &lt;user_id&gt; или reply на сообщение",
"• /admin_del &lt;user_id&gt;",
"• /ownership — текущее распределение routing-настроек",
"• /mute_list — список mute rules",
] ]
) )
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 = [
"<b>GlitchUp Bot</b>",
f"Синхронизация: {escape(sync_value)}",
"",
"Откройте нужную сводку кнопками ниже.",
]
if is_admin:
lines.extend(["", "Администрирование доступно через кнопку ниже."])
return "\n".join(lines) return "\n".join(lines)
def _help_monitoring_text() -> str: def _admin_overview_text() -> str:
return "\n".join( return "\n".join(
[ [
"<b>Обзор и мониторинг</b>", "<b>Сводки</b>",
"", "",
"Здесь собраны основные срезы по состоянию проектов.", "Быстрый доступ к основным экранам без лишних разделов.",
"",
"• <b>Сводка за неделю</b> — общая картина по новым issues и regressions",
"• <b>Сегодня</b> — свежие проблемы за текущий день",
"• <b>Топ issues</b> — самые шумные ошибки по числу событий",
"• <b>Старые issues</b> — незакрытые проблемы, которые давно висят",
]
)
def _help_releases_text() -> str:
return "\n".join(
[
"<b>Релизы и версии</b>",
"",
"Раздел помогает быстро понять, после каких релизов появились незакрытые issues.",
"",
"• <b>Список релизов</b> покажет версии, у которых есть активные проблемы",
"• <b>Как открыть релиз</b> подскажет, как перейти к деталям по конкретной версии",
]
)
def _help_release_guide_text() -> str:
return "\n".join(
[
"<b>Как смотреть детали релиза</b>",
"",
"1. Открой <b>Список релизов</b> и скопируй нужную версию.",
"2. Выполни команду <code>/release &lt;version&gt;</code>.",
"3. Бот покажет issues, связанные именно с этим релизом.",
"",
"Пример: <code>/release 2026.03.27</code>",
]
)
def _help_subscriptions_text() -> str:
return "\n".join(
[
"<b>Подписки</b>",
"",
"Здесь можно управлять личными уведомлениями в DM.",
"",
"• <b>backend</b> — проблемы серверной части",
"• <b>frontend</b> — проблемы клиентских приложений и web",
"",
"Подписка добавляет вас в runtime-настройки без редактирования `.env`.",
] ]
) )
@@ -248,13 +196,12 @@ def _admin_text() -> str:
[ [
"<b>Админ-панель GlitchUp Bot</b>", "<b>Админ-панель GlitchUp Bot</b>",
"", "",
"Панель разделена на понятные зоны, чтобы не искать нужное действие в длинном списке.", "Здесь только практичные разделы:",
"", "• синхронизация",
"<b>Центр синхронизации</b> — запуск sync и проверка статуса", "основные сводки",
"<b>Сводки и мониторинг</b> — основные обзорные экраны", "получатели по backend/frontend",
"<b>Администраторы</b> — список и управление доступом", "администраторы",
"<b>Ownership и topics</b> — текущая маршрутизация", "routing и mute rules",
"• <b>Mute rules</b> — правила скрытия шумных событий",
] ]
) )
@@ -262,20 +209,31 @@ def _admin_text() -> str:
def _admin_sync_text() -> str: def _admin_sync_text() -> str:
return "\n".join( return "\n".join(
[ [
"<b>Центр синхронизации</b>", "<b>Синхронизация</b>",
"", "",
"Отсюда удобно запускать ручной sync и смотреть, " "Отсюда удобно запускать ручной sync и проверять, "
"когда данные обновлялись в последний раз.", "когда данные обновлялись в последний раз.",
] ]
) )
def _admin_overview_text() -> str: def _admin_recipients_text() -> str:
return "\n".join( return "\n".join(
[ [
"<b>Сводки и мониторинг</b>", "<b>Получатели уведомлений</b>",
"", "",
"Быстрый доступ к основным обзорным экранам для ручной проверки состояния проектов.", "Выберите группу и назначайте Telegram ID через кнопки.",
"Самоподписка пользователей отключена.",
]
)
def _recipient_group_text(group_name: str) -> str:
return "\n".join(
[
f"<b>{escape(group_name.capitalize())} получатели</b>",
"",
"Можно посмотреть список, добавить новый Telegram ID или удалить существующий.",
] ]
) )
@@ -285,8 +243,7 @@ def _admin_guide_text() -> str:
[ [
"<b>Подсказка по админке</b>", "<b>Подсказка по админке</b>",
"", "",
"Через кнопки удобно смотреть состояние системы, " "Почти всё вынесено в кнопки и короткие сценарии.",
"а точечные настройки меняются командами.",
"", "",
"• <code>/admin_add 123456</code> — добавить администратора", "• <code>/admin_add 123456</code> — добавить администратора",
"• <code>/admin_add</code> ответом на сообщение — добавить автора сообщения", "• <code>/admin_add</code> ответом на сообщение — добавить автора сообщения",
@@ -344,6 +301,18 @@ def _extract_target_user_id(message: Message) -> int | None:
return 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"<b>{title} получатели</b>\n\nСписок пуст."
lines = [f"<b>{title} получатели</b>", ""]
for user_id in subscribers:
lines.append(f"• <code>{user_id}</code>")
return "\n".join(lines)
async def _require_admin(message: Message) -> bool: async def _require_admin(message: Message) -> bool:
if await _is_admin_user(_sender_id(message)): if await _is_admin_user(_sender_id(message)):
return True return True
@@ -594,7 +563,7 @@ async def _mute_rules_text() -> str:
async def cmd_start(message: Message) -> None: async def cmd_start(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await message.answer(
_help_text(is_admin), await _start_text(is_admin),
reply_markup=help_home_keyboard(is_admin), reply_markup=help_home_keyboard(is_admin),
disable_web_page_preview=True, disable_web_page_preview=True,
) )
@@ -636,100 +605,38 @@ async def cb_help_actions(callback: CallbackQuery) -> None:
reply_markup=help_home_keyboard(is_admin), reply_markup=help_home_keyboard(is_admin),
) )
return 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": if action == "week":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_digest(refresh=True), lambda: build_digest(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
return return
if action == "today": if action == "today":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_today_summary(refresh=True), lambda: build_today_summary(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
return return
if action == "top": if action == "top":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_top_issues(refresh=True), lambda: build_top_issues(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
return return
if action == "stale": if action == "stale":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_stale_issues(refresh=True), lambda: build_stale_issues(refresh=False),
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,
back_callback="help:open", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
return 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") @router.callback_query(F.data == "admin:open")
@@ -767,6 +674,65 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
reply_markup=admin_overview_keyboard(), reply_markup=admin_overview_keyboard(),
) )
return 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"<b>{escape(group_name.capitalize())}</b>",
"",
"Отправьте следующим сообщением 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"<b>{escape(group_name.capitalize())}</b>",
"",
"Отправьте следующим сообщением Telegram ID, который нужно удалить.",
]
),
reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"),
)
return
if action == "admins": if action == "admins":
await _deliver_result( await _deliver_result(
callback, callback,
@@ -813,19 +779,10 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
admin_mode=True, admin_mode=True,
) )
return 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": if action == "today":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_today_summary(refresh=True), lambda: build_today_summary(refresh=False),
back_callback="admin:menu:overview", back_callback="admin:menu:overview",
is_admin=True, is_admin=True,
admin_mode=True, admin_mode=True,
@@ -834,7 +791,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "week": if action == "week":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_digest(refresh=True), lambda: build_digest(refresh=False),
back_callback="admin:menu:overview", back_callback="admin:menu:overview",
is_admin=True, is_admin=True,
admin_mode=True, admin_mode=True,
@@ -843,7 +800,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "top": if action == "top":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_top_issues(refresh=True), lambda: build_top_issues(refresh=False),
back_callback="admin:menu:overview", back_callback="admin:menu:overview",
is_admin=True, is_admin=True,
admin_mode=True, admin_mode=True,
@@ -852,7 +809,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
if action == "stale": if action == "stale":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_stale_issues(refresh=True), lambda: build_stale_issues(refresh=False),
back_callback="admin:menu:overview", back_callback="admin:menu:overview",
is_admin=True, is_admin=True,
admin_mode=True, admin_mode=True,
@@ -916,8 +873,8 @@ async def cmd_week(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result( await _deliver_result(
message, message,
await build_digest(refresh=True), await build_digest(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
@@ -927,8 +884,8 @@ async def cmd_today(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result( await _deliver_result(
message, message,
await build_today_summary(refresh=True), await build_today_summary(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
@@ -943,8 +900,8 @@ async def cmd_project(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result( await _deliver_result(
message, message,
await build_project_summary(args[1].strip(), refresh=True), await build_project_summary(args[1].strip(), refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
@@ -954,8 +911,8 @@ async def cmd_top(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result( await _deliver_result(
message, message,
await build_top_issues(refresh=True), await build_top_issues(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
@@ -965,37 +922,20 @@ async def cmd_stale(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result( await _deliver_result(
message, message,
await build_stale_issues(refresh=True), await build_stale_issues(refresh=False),
back_callback="help:menu:monitoring", back_callback="help:open",
is_admin=is_admin, is_admin=is_admin,
) )
@router.message(Command("releases")) @router.message(Command("releases"))
async def cmd_releases(message: Message) -> None: async def cmd_releases(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message)) await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
await _deliver_result(
message,
await build_release_summary(refresh=True),
back_callback="help:menu:releases",
is_admin=is_admin,
)
@router.message(Command("release")) @router.message(Command("release"))
async def cmd_release(message: Message) -> None: async def cmd_release(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else [] await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
if len(args) < 2:
await message.answer("Использование: /release &lt;version&gt;")
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,
)
@router.message(Command("sync_status")) @router.message(Command("sync_status"))
@@ -1090,38 +1030,57 @@ async def cmd_admin_del(message: Message) -> None:
) )
@router.message(Command("subscribe")) @router.message(F.text)
async def cmd_subscribe(message: Message) -> None: async def cmd_pending_recipient_input(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else [] user_id = _sender_id(message)
if len(args) < 2: if user_id is None or user_id not in PENDING_RECIPIENT_ACTIONS:
await message.answer("Использование: /subscribe &lt;backend|frontend&gt;") return
if not await _require_admin(message):
return return
is_admin = await _is_admin_user(_sender_id(message)) raw_value = (message.text or "").strip()
await _handle_subscription_action( if raw_value.startswith("/"):
message, return
args[1].strip().lower(), if not raw_value.lstrip("-").isdigit():
"subscribe", await message.answer("Нужен числовой Telegram ID.")
_sender_id(message), return
reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
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 <code>{target_id}</code> добавлен в группу "
f"<b>{escape(group_name)}</b>."
) )
else:
removed = await remove_subscriber(group_name, target_id)
text = (
f"Telegram ID <code>{target_id}</code> удалён из группы "
f"<b>{escape(group_name)}</b>."
if removed
else f"Telegram ID <code>{target_id}</code> не найден в группе "
f"<b>{escape(group_name)}</b>."
)
await _deliver_result(
message,
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")) @router.message(Command("unsubscribe"))
async def cmd_unsubscribe(message: Message) -> None: async def cmd_unsubscribe(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else [] await message.answer("Самоподписка отключена. Получателей настраивает администратор.")
if len(args) < 2:
await message.answer("Использование: /unsubscribe &lt;backend|frontend&gt;")
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),
)
@router.message(Command("ownership")) @router.message(Command("ownership"))

View File

@@ -24,50 +24,15 @@ def _add_pagination_row(
def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup: def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring") builder.button(text="Сегодня", callback_data="help:today")
builder.button(text="Релизы и версии", callback_data="help:menu:releases") builder.button(text="Неделя", callback_data="help:week")
builder.button(text="Подписки", callback_data="help:menu:subscriptions") builder.button(text="Самые шумные", callback_data="help:top")
builder.button(text="Статус синхронизации", callback_data="help:sync_status") builder.button(text="Давно висят", callback_data="help:stale")
if is_admin: if is_admin:
builder.button(text="Админ-панель", callback_data="admin:open") builder.button(text="Админ-панель", callback_data="admin:open")
builder.adjust(2, 2, 1) builder.adjust(2, 2, 1)
return builder.as_markup() else:
builder.adjust(2, 2)
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")
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)
return builder.as_markup() return builder.as_markup()
@@ -104,14 +69,15 @@ def help_result_keyboard(
def admin_home_keyboard() -> InlineKeyboardMarkup: def admin_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() 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="Администраторы", 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="Mute rules", callback_data="admin:mute_list")
builder.button(text="Инструкция", callback_data="admin:guide") builder.button(text="Инструкция", callback_data="admin:guide")
builder.button(text="Пользовательское меню", callback_data="help:open") builder.button(text="Пользовательское меню", callback_data="help:open")
builder.adjust(2, 2, 2, 1) builder.adjust(2, 2, 2, 1, 1)
return builder.as_markup() return builder.as_markup()
@@ -127,13 +93,31 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup:
def admin_overview_keyboard() -> InlineKeyboardMarkup: def admin_overview_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="Сводка за неделю", callback_data="admin:week")
builder.button(text="Сегодня", callback_data="admin:today") builder.button(text="Сегодня", callback_data="admin:today")
builder.button(text="Топ issues", callback_data="admin:top") builder.button(text="Неделя", callback_data="admin:week")
builder.button(text="Старые issues", callback_data="admin:stale") builder.button(text="Самые шумные", callback_data="admin:top")
builder.button(text="Релизы", callback_data="admin:releases") builder.button(text="Давно висят", callback_data="admin:stale")
builder.button(text="Назад", callback_data="admin:open") 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() return builder.as_markup()

View File

@@ -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.config import settings
from glitchup_bot.glitchtip_client.client import close_glitchtip_client from glitchup_bot.glitchtip_client.client import close_glitchtip_client
from glitchup_bot.models.database import dispose_engine 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 from glitchup_bot.tasks.scheduler import setup_scheduler, shutdown_scheduler
logging.basicConfig( logging.basicConfig(
@@ -37,6 +38,7 @@ async def shutdown_resources() -> None:
async def main() -> None: async def main() -> None:
logger.info("GlitchUp Bot starting") logger.info("GlitchUp Bot starting")
await warm_issue_cache_on_startup()
setup_scheduler() setup_scheduler()
api_task = asyncio.create_task(start_api(), name="api") api_task = asyncio.create_task(start_api(), name="api")

View File

@@ -1,4 +1,6 @@
import re
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from html import escape from html import escape
@@ -11,12 +13,96 @@ from glitchup_bot.services.sync_service import (
) )
def _issue_label(issue: IssueSnapshot) -> str: @dataclass(slots=True)
title = escape(issue.title) class AggregatedIssue:
slug = escape(issue.project_slug) 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'<a href="{escape(link, quote=True)}">{safe_title}</a> ({safe_slug})'
return f"{safe_title} ({safe_slug})"
def _format_issue_line(issue: AggregatedIssue, *, include_seen: bool = False) -> str:
parts = [f"• <b>{issue.event_count}</b> событий"]
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: if issue.link:
return f'<a href="{escape(issue.link, quote=True)}">{title}</a> ({slug})' current.link = issue.link
return f"{title} ({slug})" 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( async def _load_issues(
@@ -35,41 +121,32 @@ async def _load_issues(
async def build_digest(refresh: bool = True) -> str: async def build_digest(refresh: bool = True) -> str:
now = datetime.now(UTC) now = datetime.now(UTC)
week_ago = now - timedelta(days=7) week_ago = now - timedelta(days=7)
issues = await _load_issues(refresh=refresh) aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
new_issues: list[IssueSnapshot] = [] new_issues = [
regressions: list[IssueSnapshot] = [] issue for issue in aggregated if issue.first_seen and issue.first_seen >= week_ago
stale: list[IssueSnapshot] = [] ]
by_release: dict[str, list[IssueSnapshot]] = defaultdict(list) 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( 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: if issue.first_seen and issue.first_seen >= week_ago:
new_issues.append(issue) project_stats[issue.project_slug]["issues"] += 1
project_stats[issue.project_slug]["new"] += 1
if issue.is_regression: if issue.is_regression:
regressions.append(issue)
project_stats[issue.project_slug]["regression"] += 1 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 project_stats[issue.project_slug]["events"] += issue.event_count
lines = [ lines = [
"<b>📊 GlitchTip digest за неделю</b>", "<b>📊 Сводка за неделю</b>",
"", "",
"<b>Всего:</b>", "<b>Всего:</b>",
f"• новых issues: {len(new_issues)}", f"• новых групп проблем: {len(new_issues)}",
f"• regressions: {len(regressions)}", 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("<b>По проектам:</b>") lines.append("<b>По проектам:</b>")
for slug, stats in sorted( for slug, stats in sorted(
project_stats.items(), project_stats.items(),
key=lambda item: (item[1]["new"], item[1]["regression"], item[0]), key=lambda item: (item[1]["issues"], item[1]["events"], item[0]),
reverse=True,
)[:5]:
parts = [f"{stats['new']} новых"]
if stats["regression"]:
parts.append(f"{stats['regression']} regression")
lines.append(f"• <b>{escape(slug)}</b> — {', '.join(parts)}")
lines.append("")
if by_release:
lines.append("<b>После релизов:</b>")
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])),
reverse=True, reverse=True,
)[:5]: )[:5]:
lines.append( lines.append(
f"• <b>{escape(release_name)}</b> — " f"• <b>{escape(slug)}</b> — {stats['issues']} групп, {stats['events']} событий"
f"{len(release_issues)} issues, "
f"{sum(issue.event_count for issue in release_issues)} событий"
) )
lines.append("") lines.append("")
if top_noisy: if top_noisy:
lines.append("<b>Топ шумных:</b>") lines.append("<b>Самые шумные:</b>")
for issue in top_noisy[:5]: for issue in top_noisy[:5]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий") lines.append(_format_issue_line(issue))
lines.append("") lines.append("")
if stale: if stale:
lines.append("<b>Хвосты:</b>") lines.append("<b>Давно висят:</b>")
for issue in sorted( for issue in sorted(
stale, stale,
key=lambda item: (now - (item.first_seen or now)).days, key=lambda item: (now - (item.first_seen or now)).days,
reverse=True, reverse=True,
)[:5]: )[:5]:
age = (now - (issue.first_seen or now)).days age = (now - (issue.first_seen or now)).days
lines.append(f"{_issue_label(issue)}{age} дн. без разбора") lines.append(
f"• <b>{age} дн.</b> — "
f"{_issue_label(issue.title, issue.project_slug, issue.link)}"
)
if len(lines) == 7: if len(lines) == 7:
lines.append("Все чисто! Новых проблем нет.") lines.append("Все спокойно. Новых проблем нет.")
return "\n".join(lines) 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: async def build_today_summary(refresh: bool = True) -> str:
now = datetime.now(UTC) now = datetime.now(UTC)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) 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 = [ 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: if not today_issues:
return "За сегодня новых issues не обнаружено." return "За сегодня новых проблем не обнаружено."
lines = [f"<b>📋 Сегодня: {len(today_issues)} новых issues</b>", ""] total_events = sum(issue.event_count for issue in today_issues)
lines = [
f"<b>📋 Сегодня: {len(today_issues)} групп проблем</b>",
f"• событий за сегодня: {total_events}",
"",
]
for issue in sorted( for issue in sorted(
today_issues, today_issues,
key=lambda item: item.first_seen or now, key=lambda item: (item.event_count, item.last_seen or now),
reverse=True, reverse=True,
)[:10]: )[:10]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий") lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines) return "\n".join(lines)
async def build_project_summary(project_slug: str, refresh: bool = True) -> str: async def build_project_summary(project_slug: str, refresh: bool = True) -> str:
issues = await _load_issues([project_slug], refresh=refresh) aggregated = _aggregate_issues(await _load_issues([project_slug], refresh=refresh))
if not issues: if not aggregated:
return f"<b>{escape(project_slug)}</b>: нет unresolved issues." return f"<b>{escape(project_slug)}</b>: нет активных проблем."
total_events = sum(issue.event_count for issue in issues) total_events = sum(issue.event_count for issue in aggregated)
regressions = [issue for issue in issues if issue.is_regression] regressions = [issue for issue in aggregated if issue.is_regression]
lines = [ lines = [
f"<b>📦 {escape(project_slug)}</b>", f"<b>📦 {escape(project_slug)}</b>",
"", "",
f"unresolved issues: {len(issues)}", f"групп проблем: {len(aggregated)}",
f"• всего событий: {total_events}", f"• всего событий: {total_events}",
f"• regressions: {len(regressions)}", f"• regressions: {len(regressions)}",
"", "",
@@ -162,23 +232,23 @@ async def build_project_summary(project_slug: str, refresh: bool = True) -> str:
] ]
for issue in sorted( for issue in sorted(
issues, aggregated,
key=lambda item: item.last_seen or datetime.min.replace(tzinfo=UTC), key=lambda item: item.last_seen or datetime.min.replace(tzinfo=UTC),
reverse=True, reverse=True,
)[:5]: )[:5]:
lines.append(f"{_issue_label(issue)}{issue.event_count} событий") lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines) return "\n".join(lines)
async def build_top_issues(limit: int = 10, refresh: bool = True) -> str: async def build_top_issues(limit: int = 10, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh) aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
if not issues: if not aggregated:
return "Нет unresolved issues." return "Нет активных проблем."
lines = ["<b>🔊 Топ шумных issues</b>", ""] lines = ["<b>🔊 Самые шумные проблемы</b>", ""]
for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]: for issue in sorted(aggregated, key=lambda item: item.event_count, reverse=True)[:limit]:
lines.append(f"• <b>{issue.event_count}</b> событий — {_issue_label(issue)}") lines.append(_format_issue_line(issue, include_seen=True))
return "\n".join(lines) return "\n".join(lines)
@@ -189,44 +259,48 @@ async def build_stale_issues(
refresh: bool = True, refresh: bool = True,
) -> str: ) -> str:
now = datetime.now(UTC) now = datetime.now(UTC)
issues = await _load_issues(refresh=refresh) aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
stale = [ 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: if not stale:
return "Нет старых незакрытых issues (> 7 дней)." return "Нет старых незакрытых проблем."
lines = ["<b>🕸 Старые незакрытые issues</b>", ""] lines = ["<b>🕸 Старые незакрытые проблемы</b>", ""]
for issue in sorted( for issue in sorted(
stale, stale,
key=lambda item: now - (item.first_seen or now), key=lambda item: now - (item.first_seen or now),
reverse=True, reverse=True,
)[:limit]: )[:limit]:
age = (now - (issue.first_seen or now)).days age = (now - (issue.first_seen or now)).days
lines.append(f"• <b>{age} дн.</b> — {_issue_label(issue)} ({issue.event_count} событий)") lines.append(
f"• <b>{age} дн.</b> — {_issue_label(issue.title, issue.project_slug, issue.link)}"
)
return "\n".join(lines) return "\n".join(lines)
async def build_release_summary(limit: int = 10, refresh: bool = True) -> str: async def build_release_summary(limit: int = 10, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh) aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
grouped: dict[str, list[IssueSnapshot]] = defaultdict(list) grouped: dict[str, list[AggregatedIssue]] = defaultdict(list)
for issue in issues: for issue in aggregated:
if issue.release: if issue.release:
grouped[issue.release].append(issue) grouped[issue.release].append(issue)
if not grouped: if not grouped:
return "Релизы в данных GlitchTip не обнаружены." return "Релизы в данных GlitchTip не обнаружены."
lines = ["<b>🚀 Релизы с незакрытыми issue</b>", ""] lines = ["<b>🚀 Релизы с проблемами</b>", ""]
for release_name, release_issues in sorted( for release_name, release_issues in sorted(
grouped.items(), grouped.items(),
key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])), key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])),
reverse=True, reverse=True,
)[:limit]: )[:limit]:
lines.append( lines.append(
f"• <b>{escape(release_name)}</b> — {len(release_issues)} issues, " f"• <b>{escape(release_name)}</b> — {len(release_issues)} групп, "
f"{sum(issue.event_count for issue in 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: async def build_release_detail(release_name: str, refresh: bool = True) -> str:
issues = await _load_issues(refresh=refresh) aggregated = _aggregate_issues(await _load_issues(refresh=refresh))
matched = [issue for issue in issues if issue.release == release_name] matched = [issue for issue in aggregated if issue.release == release_name]
if not matched: if not matched:
return f"Для релиза <b>{escape(release_name)}</b> незакрытых issues не найдено." return f"Для релиза <b>{escape(release_name)}</b> активных проблем не найдено."
lines = [f"<b>🚀 Релиз {escape(release_name)}</b>", ""] lines = [f"<b>🚀 Релиз {escape(release_name)}</b>", ""]
for issue in sorted(matched, key=lambda item: item.event_count, reverse=True)[:10]: for issue in sorted(matched, key=lambda item: item.event_count, reverse=True)[:10]:
suffix = " regression" if issue.is_regression else "" lines.append(_format_issue_line(issue, include_seen=True))
lines.append(f"{_issue_label(issue)}{issue.event_count} событий{suffix}")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -1,4 +1,5 @@
import logging import logging
from asyncio import Lock, wait_for
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
@@ -13,6 +14,7 @@ from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.sync import SyncState from glitchup_bot.models.sync import SyncState
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_sync_lock = Lock()
@dataclass(slots=True) @dataclass(slots=True)
@@ -106,6 +108,7 @@ async def mark_sync_success(source: str) -> None:
async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary: async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary:
async with _sync_lock:
slugs = project_slugs or _configured_project_slugs() slugs = project_slugs or _configured_project_slugs()
client = get_glitchtip_client() client = get_glitchtip_client()
snapshots: list[IssueSnapshot] = [] snapshots: list[IssueSnapshot] = []
@@ -188,6 +191,28 @@ async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary:
) )
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( async def load_issue_snapshots(
project_slugs: list[str] | None = None, project_slugs: list[str] | None = None,
*, *,

View File

@@ -46,12 +46,12 @@ async def test_build_digest_aggregates_projects(monkeypatch):
text = await digest_builder.build_digest() text = await digest_builder.build_digest()
assert "новых issues: 1" in text assert "новых групп проблем: 1" in text
assert "regressions: 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 "backend-production" in text
assert "Old frontend issue" in text assert "Old frontend issue" in text
assert "2026.03.20" in text assert "12 событий" in text
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -94,7 +94,8 @@ async def test_build_today_summary_limits_to_today(monkeypatch):
text = await digest_builder.build_today_summary() 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 "Today issue" in text
assert "Old issue" not 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 "backend-production" in text
assert "Project issue" in text assert "Project issue" in text
assert "5 событий" in text assert "всего событий: 5" in text
@pytest.mark.asyncio @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") detail = await digest_builder.build_release_detail("2026.03.27")
assert "2026.03.27" in summary assert "2026.03.27" in summary
assert "2 issues" in summary assert "2 групп" in summary
assert "Release issue" in detail 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): async def _async_value(value):
return value return value