From 737a6b6f0f508920b881b48b6f7c8501890c7819 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Fri, 27 Feb 2026 17:18:39 +0300 Subject: [PATCH] =?UTF-8?q?=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F=D1=85/=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B8=D1=82=D0=B0=D1=85/=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=D0=B5=20PR=20=D0=B8=20/reminder=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 181 insertions(+), 27 deletions(-) diff --git a/main.py b/main.py index 5d1ecd8..d6c60f1 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import asyncio import hashlib import hmac +import json import os import random import sqlite3 @@ -270,6 +271,29 @@ class Storage: (key, value), ) + def get_reminder_time(self, telegram_chat_id: int) -> str | None: + return self.get_kv(f"reminder_time:{telegram_chat_id}") + + def set_reminder_time(self, telegram_chat_id: int, time_str: str) -> None: + self.set_kv(f"reminder_time:{telegram_chat_id}", time_str) + + def get_last_reminder_date(self, telegram_chat_id: int) -> str | None: + return self.get_kv(f"last_reminder:{telegram_chat_id}") + + def set_last_reminder_date(self, telegram_chat_id: int, date_str: str) -> None: + self.set_kv(f"last_reminder:{telegram_chat_id}", date_str) + + def get_assignments_for_pr(self, repo_full_name: str, pr_number: int) -> list[sqlite3.Row]: + with self._connect() as conn: + return conn.execute( + """ + SELECT author_login, reviewer_login + FROM assignments + WHERE repo_full_name = ? AND pr_number = ? AND status = 'open' + """, + (repo_full_name, pr_number), + ).fetchall() + class TelegramClient: def __init__(self, token: str) -> None: @@ -355,6 +379,27 @@ def choose_reviewer(candidates: list[RegisteredUser]) -> RegisteredUser: return random.choice(shortlist) +def get_pr_participant_chat_ids( + repo_full_name: str, pr_number: int, exclude_login: str | None = None +) -> list[int]: + """Возвращает chat_id участников PR (автор и ревьюеры), зарегистрированных в боте. exclude_login не включается.""" + rows = storage.get_assignments_for_pr(repo_full_name, pr_number) + if not rows: + return [] + logins: set[str] = set() + for r in rows: + logins.add(r["author_login"]) + logins.add(r["reviewer_login"]) + if exclude_login: + logins.discard(exclude_login) + chat_ids: list[int] = [] + for login in logins: + user = storage.get_user_by_gitea_login(login) + if user: + chat_ids.append(user.telegram_chat_id) + return chat_ids + + async def handle_telegram_command(update: dict[str, Any]) -> None: message = update.get("message") or {} chat = message.get("chat") or {} @@ -376,6 +421,7 @@ async def handle_telegram_command(update: dict[str, Any]) -> None: "Команды:\n" "/register \n" "/away \n" + "/reminder [ЧЧ:ММ] — время ежедневного напоминания о ревью\n" "/myreviews" ), ) @@ -421,6 +467,33 @@ async def handle_telegram_command(update: dict[str, Any]) -> None: await telegram_client.send_message(chat_id, "Период недоступности сохранен.") return + if cmd == "/reminder": + if len(parts) == 1: + t = storage.get_reminder_time(int(chat_id)) + msg = f"Время напоминания: {t or REMINDER_TIME} (по умолчанию)" if t else f"Время напоминания: {REMINDER_TIME} (глобальное по умолчанию)" + await telegram_client.send_message(chat_id, msg) + return + if len(parts) != 2: + await telegram_client.send_message( + chat_id, + "Использование: /reminder [ЧЧ:ММ], например /reminder 09:00", + ) + return + time_str = parts[1].strip() + if len(time_str) != 5 or time_str[2] != ":": + await telegram_client.send_message(chat_id, "Формат времени: ЧЧ:ММ (например 09:00).") + return + try: + h, m = int(time_str[:2]), int(time_str[3:5]) + if not (0 <= h <= 23 and 0 <= m <= 59): + raise ValueError("out of range") + except ValueError: + await telegram_client.send_message(chat_id, "Некорректное время. Используйте ЧЧ:ММ (например 09:00).") + return + storage.set_reminder_time(int(chat_id), time_str) + await telegram_client.send_message(chat_id, f"Напоминания будут приходить ежедневно в {time_str}.") + return + if cmd == "/myreviews": me = storage.get_user_by_chat_id(int(chat_id)) if me is None: @@ -485,9 +558,74 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None: repository = payload.get("repository") or {} repo_full_name = repository.get("full_name") pr_number = pull_request.get("number") + action = payload.get("action") if not repo_full_name or not pr_number: return + participant_chat_ids = get_pr_participant_chat_ids(repo_full_name, int(pr_number)) storage.close_assignments_for_pr(repo_full_name, int(pr_number)) + status_label = "смержен" if action == "merged" else "закрыт" + title = (pull_request.get("title") or "").strip() or f"#{pr_number}" + msg = f"PR {repo_full_name}#{pr_number} {status_label}: {title}" + for chat_id in participant_chat_ids: + await telegram_client.send_message(chat_id, msg) + + +async def handle_pr_synchronize(payload: dict[str, Any]) -> None: + """Новые коммиты в PR — уведомляем назначенных ревьюеров.""" + pull_request = payload.get("pull_request") or {} + repository = payload.get("repository") or {} + repo_full_name = repository.get("full_name") + pr_number = pull_request.get("number") + if not repo_full_name or not pr_number: + return + rows = storage.get_assignments_for_pr(repo_full_name, int(pr_number)) + author_login = (pull_request.get("user") or {}).get("login") or "" + msg = f"В PR {repo_full_name}#{pr_number} добавлены новые коммиты (автор: {author_login})." + for r in rows: + reviewer_login = r["reviewer_login"] + user = storage.get_user_by_gitea_login(reviewer_login) + if user: + await telegram_client.send_message(user.telegram_chat_id, msg) + + +async def handle_issue_comment(payload: dict[str, Any]) -> None: + """Новый комментарий в PR — уведомляем автора и ревьюеров (кроме автора комментария).""" + issue = payload.get("issue") or {} + if not issue.get("pull_request"): + return # это issue, не PR + repository = payload.get("repository") or {} + repo_full_name = repository.get("full_name") + pr_number = issue.get("number") + comment = payload.get("comment") or {} + commenter_login = (comment.get("user") or {}).get("login") or "" + body = (comment.get("body") or "").strip()[:200] + if not repo_full_name or not pr_number: + return + msg = f"Новый комментарий в PR {repo_full_name}#{pr_number}: {body}" + for chat_id in get_pr_participant_chat_ids(repo_full_name, int(pr_number), exclude_login=commenter_login): + await telegram_client.send_message(chat_id, msg) + + +async def handle_pr_reviewed(payload: dict[str, Any]) -> None: + """Изменение статуса ревью (approved / request changes) — уведомляем участников.""" + pull_request = payload.get("pull_request") or {} + repository = payload.get("repository") or {} + review = payload.get("review") or {} + repo_full_name = repository.get("full_name") + pr_number = pull_request.get("number") + if not repo_full_name or not pr_number: + return + state = (review.get("state") or "").lower() + reviewer_login = (review.get("user") or {}).get("login") or "" + if state == "approved": + status_text = "одобрен" + elif state in ("request_changes", "request changes"): + status_text = "запрошены правки" + else: + status_text = state or "обновлён" + msg = f"PR {repo_full_name}#{pr_number}: ревью {status_text} ({reviewer_login})." + for chat_id in get_pr_participant_chat_ids(repo_full_name, int(pr_number), exclude_login=reviewer_login): + await telegram_client.send_message(chat_id, msg) async def reminder_loop() -> None: @@ -495,26 +633,28 @@ async def reminder_loop() -> None: try: now = utc_now() hh_mm = now.strftime("%H:%M") - if hh_mm == REMINDER_TIME: - day_key = now.date().isoformat() - already_sent = storage.get_kv("last_reminder_date") - if already_sent != day_key: - users = storage.get_all_users() - for user in users: - rows = storage.get_open_assignments_for_reviewer(user.gitea_login) - if not rows: - continue - lines = [f"Ежедневное напоминание ({len(rows)} открыто):"] - for row in rows: - created = row["created_at"][:10] - lines.append( - f"- {row['repo_full_name']}#{row['pr_number']} (автор: {row['author_login']}, с {created})" - ) - await telegram_client.send_message(user.telegram_chat_id, "\n".join(lines)) - storage.set_kv("last_reminder_date", day_key) + day_key = now.date().isoformat() + users = storage.get_all_users() + for user in users: + reminder_time = storage.get_reminder_time(user.telegram_chat_id) or REMINDER_TIME + if hh_mm != reminder_time: + continue + already_sent = storage.get_last_reminder_date(user.telegram_chat_id) + if already_sent == day_key: + continue + rows = storage.get_open_assignments_for_reviewer(user.gitea_login) + if not rows: + continue + lines = [f"Ежедневное напоминание ({len(rows)} открыто):"] + for row in rows: + created = row["created_at"][:10] + lines.append( + f"- {row['repo_full_name']}#{row['pr_number']} (автор: {row['author_login']}, с {created})" + ) + await telegram_client.send_message(user.telegram_chat_id, "\n".join(lines)) + storage.set_last_reminder_date(user.telegram_chat_id, day_key) await asyncio.sleep(30) except Exception: - # Поддерживаем работу сервиса даже при ошибке в одной итерации. await asyncio.sleep(5) @@ -563,13 +703,27 @@ async def gitea_webhook( body = await request.body() if not verify_gitea_signature(body, x_gitea_signature): raise HTTPException(status_code=401, detail="Некорректная подпись") - payload = await request.json() - if x_gitea_event != "pull_request": - return {"ok": "ignored"} - action = payload.get("action") - if action == "opened": - await handle_pr_opened(payload) - elif action in ("closed", "merged"): - await handle_pr_closed(payload) - return {"ok": "true"} + payload = json.loads(body.decode("utf-8")) + + if x_gitea_event == "issue_comment": + await handle_issue_comment(payload) + return {"ok": "true"} + + if x_gitea_event == "pull_request_review" and payload.get("review"): + await handle_pr_reviewed(payload) + return {"ok": "true"} + + if x_gitea_event == "pull_request": + action = payload.get("action") + if action == "opened": + await handle_pr_opened(payload) + elif action in ("closed", "merged"): + await handle_pr_closed(payload) + elif action == "synchronize": + await handle_pr_synchronize(payload) + elif action == "reviewed" and payload.get("review"): + await handle_pr_reviewed(payload) + return {"ok": "true"} + + return {"ok": "ignored"}