уведомления о комментариях/коммитах/статусе PR и /reminder для времени напоминаний
This commit is contained in:
parent
8a1b3b3824
commit
737a6b6f0f
170
main.py
170
main.py
@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@ -270,6 +271,29 @@ class Storage:
|
|||||||
(key, value),
|
(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:
|
class TelegramClient:
|
||||||
def __init__(self, token: str) -> None:
|
def __init__(self, token: str) -> None:
|
||||||
@ -355,6 +379,27 @@ def choose_reviewer(candidates: list[RegisteredUser]) -> RegisteredUser:
|
|||||||
return random.choice(shortlist)
|
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:
|
async def handle_telegram_command(update: dict[str, Any]) -> None:
|
||||||
message = update.get("message") or {}
|
message = update.get("message") or {}
|
||||||
chat = message.get("chat") or {}
|
chat = message.get("chat") or {}
|
||||||
@ -376,6 +421,7 @@ async def handle_telegram_command(update: dict[str, Any]) -> None:
|
|||||||
"Команды:\n"
|
"Команды:\n"
|
||||||
"/register <git_email> <gitea_login>\n"
|
"/register <git_email> <gitea_login>\n"
|
||||||
"/away <YYYY-MM-DD> <YYYY-MM-DD>\n"
|
"/away <YYYY-MM-DD> <YYYY-MM-DD>\n"
|
||||||
|
"/reminder [ЧЧ:ММ] — время ежедневного напоминания о ревью\n"
|
||||||
"/myreviews"
|
"/myreviews"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -421,6 +467,33 @@ async def handle_telegram_command(update: dict[str, Any]) -> None:
|
|||||||
await telegram_client.send_message(chat_id, "Период недоступности сохранен.")
|
await telegram_client.send_message(chat_id, "Период недоступности сохранен.")
|
||||||
return
|
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":
|
if cmd == "/myreviews":
|
||||||
me = storage.get_user_by_chat_id(int(chat_id))
|
me = storage.get_user_by_chat_id(int(chat_id))
|
||||||
if me is None:
|
if me is None:
|
||||||
@ -485,9 +558,74 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None:
|
|||||||
repository = payload.get("repository") or {}
|
repository = payload.get("repository") or {}
|
||||||
repo_full_name = repository.get("full_name")
|
repo_full_name = repository.get("full_name")
|
||||||
pr_number = pull_request.get("number")
|
pr_number = pull_request.get("number")
|
||||||
|
action = payload.get("action")
|
||||||
if not repo_full_name or not pr_number:
|
if not repo_full_name or not pr_number:
|
||||||
return
|
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))
|
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:
|
async def reminder_loop() -> None:
|
||||||
@ -495,12 +633,15 @@ async def reminder_loop() -> None:
|
|||||||
try:
|
try:
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
hh_mm = now.strftime("%H:%M")
|
hh_mm = now.strftime("%H:%M")
|
||||||
if hh_mm == REMINDER_TIME:
|
|
||||||
day_key = now.date().isoformat()
|
day_key = now.date().isoformat()
|
||||||
already_sent = storage.get_kv("last_reminder_date")
|
|
||||||
if already_sent != day_key:
|
|
||||||
users = storage.get_all_users()
|
users = storage.get_all_users()
|
||||||
for user in 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)
|
rows = storage.get_open_assignments_for_reviewer(user.gitea_login)
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
@ -511,10 +652,9 @@ async def reminder_loop() -> None:
|
|||||||
f"- {row['repo_full_name']}#{row['pr_number']} (автор: {row['author_login']}, с {created})"
|
f"- {row['repo_full_name']}#{row['pr_number']} (автор: {row['author_login']}, с {created})"
|
||||||
)
|
)
|
||||||
await telegram_client.send_message(user.telegram_chat_id, "\n".join(lines))
|
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)
|
await asyncio.sleep(30)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Поддерживаем работу сервиса даже при ошибке в одной итерации.
|
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
@ -563,13 +703,27 @@ async def gitea_webhook(
|
|||||||
body = await request.body()
|
body = await request.body()
|
||||||
if not verify_gitea_signature(body, x_gitea_signature):
|
if not verify_gitea_signature(body, x_gitea_signature):
|
||||||
raise HTTPException(status_code=401, detail="Некорректная подпись")
|
raise HTTPException(status_code=401, detail="Некорректная подпись")
|
||||||
payload = await request.json()
|
payload = json.loads(body.decode("utf-8"))
|
||||||
if x_gitea_event != "pull_request":
|
|
||||||
return {"ok": "ignored"}
|
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")
|
action = payload.get("action")
|
||||||
if action == "opened":
|
if action == "opened":
|
||||||
await handle_pr_opened(payload)
|
await handle_pr_opened(payload)
|
||||||
elif action in ("closed", "merged"):
|
elif action in ("closed", "merged"):
|
||||||
await handle_pr_closed(payload)
|
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": "true"}
|
||||||
|
|
||||||
|
return {"ok": "ignored"}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user