уведомления о комментариях/коммитах/статусе PR и /reminder для времени напоминаний
This commit is contained in:
parent
8a1b3b3824
commit
737a6b6f0f
170
main.py
170
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 <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"}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user