уведомления о комментариях/коммитах/статусе PR и /reminder для времени напоминаний

This commit is contained in:
Raykov-MS 2026-02-27 17:18:39 +03:00
parent 8a1b3b3824
commit 737a6b6f0f

170
main.py
View File

@ -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 <git_email> <gitea_login>\n"
"/away <YYYY-MM-DD> <YYYY-MM-DD>\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,12 +633,15 @@ 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:
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
@ -511,10 +652,9 @@ async def reminder_loop() -> None:
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)
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"}
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"}