Упрощение для балбесов
This commit is contained in:
@@ -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:
|
||||
lines = [
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>GlitchUp Bot</b>",
|
||||
"",
|
||||
"Понятное меню для просмотра ошибок, релизов и подписок.",
|
||||
"Выберите нужную сводку кнопками ниже.",
|
||||
"Пользователю доступны только базовые экраны.",
|
||||
"",
|
||||
"<b>Что можно сделать:</b>",
|
||||
"• открыть сводку за неделю или за сегодня",
|
||||
"• посмотреть топ самых шумных и старых issues",
|
||||
"• проверить последние релизы",
|
||||
"• включить или отключить личные уведомления",
|
||||
"• узнать статус последней синхронизации",
|
||||
"",
|
||||
"<b>Быстрые команды:</b>",
|
||||
"• /week, /today, /top, /stale",
|
||||
"• /releases, /release <version>",
|
||||
"• /project <slug>",
|
||||
"• /subscribe backend|frontend",
|
||||
"• /unsubscribe backend|frontend",
|
||||
]
|
||||
|
||||
if is_admin:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Для админа:</b>",
|
||||
"• /admin — панель управления",
|
||||
"• /sync — принудительная синхронизация",
|
||||
"• /admins — список администраторов",
|
||||
"• /admin_add <user_id> или reply на сообщение",
|
||||
"• /admin_del <user_id>",
|
||||
"• /ownership — текущее распределение routing-настроек",
|
||||
"• /mute_list — список mute rules",
|
||||
"• Сегодня",
|
||||
"• Неделя",
|
||||
"• Самые шумные",
|
||||
"• Давно висят",
|
||||
*(["", "• Для управления откройте админ-панель"] 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 = [
|
||||
"<b>GlitchUp Bot</b>",
|
||||
f"Синхронизация: {escape(sync_value)}",
|
||||
"",
|
||||
"Откройте нужную сводку кнопками ниже.",
|
||||
]
|
||||
if is_admin:
|
||||
lines.extend(["", "Администрирование доступно через кнопку ниже."])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _help_monitoring_text() -> str:
|
||||
def _admin_overview_text() -> str:
|
||||
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 <version></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>Центр синхронизации</b> — запуск sync и проверка статуса",
|
||||
"• <b>Сводки и мониторинг</b> — основные обзорные экраны",
|
||||
"• <b>Администраторы</b> — список и управление доступом",
|
||||
"• <b>Ownership и topics</b> — текущая маршрутизация",
|
||||
"• <b>Mute rules</b> — правила скрытия шумных событий",
|
||||
"Здесь только практичные разделы:",
|
||||
"• синхронизация",
|
||||
"• основные сводки",
|
||||
"• получатели по backend/frontend",
|
||||
"• администраторы",
|
||||
"• routing и mute rules",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -262,20 +209,31 @@ def _admin_text() -> str:
|
||||
def _admin_sync_text() -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"<b>Центр синхронизации</b>",
|
||||
"<b>Синхронизация</b>",
|
||||
"",
|
||||
"Отсюда удобно запускать ручной sync и смотреть, "
|
||||
"Отсюда удобно запускать ручной sync и проверять, "
|
||||
"когда данные обновлялись в последний раз.",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _admin_overview_text() -> str:
|
||||
def _admin_recipients_text() -> str:
|
||||
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>",
|
||||
"",
|
||||
"Через кнопки удобно смотреть состояние системы, "
|
||||
"а точечные настройки меняются командами.",
|
||||
"Почти всё вынесено в кнопки и короткие сценарии.",
|
||||
"",
|
||||
"• <code>/admin_add 123456</code> — добавить администратора",
|
||||
"• <code>/admin_add</code> ответом на сообщение — добавить автора сообщения",
|
||||
@@ -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"<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:
|
||||
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"<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":
|
||||
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(
|
||||
message,
|
||||
args[1].strip().lower(),
|
||||
"subscribe",
|
||||
_sender_id(message),
|
||||
reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin),
|
||||
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 <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"))
|
||||
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"))
|
||||
|
||||
@@ -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")
|
||||
builder.button(text="Сегодня", callback_data="help:today")
|
||||
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)
|
||||
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")
|
||||
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)
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@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'<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:
|
||||
return f'<a href="{escape(issue.link, quote=True)}">{title}</a> ({slug})'
|
||||
return f"{title} ({slug})"
|
||||
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 = [
|
||||
"<b>📊 GlitchTip digest за неделю</b>",
|
||||
"<b>📊 Сводка за неделю</b>",
|
||||
"",
|
||||
"<b>Всего:</b>",
|
||||
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("<b>По проектам:</b>")
|
||||
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"• <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])),
|
||||
key=lambda item: (item[1]["issues"], item[1]["events"], item[0]),
|
||||
reverse=True,
|
||||
)[:5]:
|
||||
lines.append(
|
||||
f"• <b>{escape(release_name)}</b> — "
|
||||
f"{len(release_issues)} issues, "
|
||||
f"{sum(issue.event_count for issue in release_issues)} событий"
|
||||
f"• <b>{escape(slug)}</b> — {stats['issues']} групп, {stats['events']} событий"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
if top_noisy:
|
||||
lines.append("<b>Топ шумных:</b>")
|
||||
lines.append("<b>Самые шумные:</b>")
|
||||
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("<b>Хвосты:</b>")
|
||||
lines.append("<b>Давно висят:</b>")
|
||||
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"• <b>{age} дн.</b> — "
|
||||
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"<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(
|
||||
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"<b>{escape(project_slug)}</b>: нет unresolved issues."
|
||||
aggregated = _aggregate_issues(await _load_issues([project_slug], refresh=refresh))
|
||||
if not aggregated:
|
||||
return f"<b>{escape(project_slug)}</b>: нет активных проблем."
|
||||
|
||||
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"<b>📦 {escape(project_slug)}</b>",
|
||||
"",
|
||||
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 = ["<b>🔊 Топ шумных issues</b>", ""]
|
||||
for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]:
|
||||
lines.append(f"• <b>{issue.event_count}</b> событий — {_issue_label(issue)}")
|
||||
lines = ["<b>🔊 Самые шумные проблемы</b>", ""]
|
||||
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 = ["<b>🕸 Старые незакрытые issues</b>", ""]
|
||||
lines = ["<b>🕸 Старые незакрытые проблемы</b>", ""]
|
||||
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"• <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)
|
||||
|
||||
|
||||
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 = ["<b>🚀 Релизы с незакрытыми issue</b>", ""]
|
||||
lines = ["<b>🚀 Релизы с проблемами</b>", ""]
|
||||
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"• <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)} событий"
|
||||
)
|
||||
|
||||
@@ -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"Для релиза <b>{escape(release_name)}</b> незакрытых issues не найдено."
|
||||
return 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]:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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,6 +108,7 @@ async def mark_sync_success(source: str) -> None:
|
||||
|
||||
|
||||
async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary:
|
||||
async with _sync_lock:
|
||||
slugs = project_slugs or _configured_project_slugs()
|
||||
client = get_glitchtip_client()
|
||||
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(
|
||||
project_slugs: list[str] | None = None,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user