Compare commits

...

10 Commits

Author SHA1 Message Date
Raykov-MS
bbac9918e1 . 2026-06-24 17:44:00 +03:00
Raykov-MS
3dcf947c68 multiply repos 2026-05-14 12:06:40 +03:00
Raykov-MS
d90c0f40f4 proxys: 2026-03-20 15:35:38 +03:00
Raykov-MS
04cf58dd1f . 2026-03-20 15:05:11 +03:00
Raykov-MS
91338ca40f add more logs 2026-03-06 12:22:38 +03:00
Raykov-MS
cbec9eddb5 add logs 2026-03-05 20:39:18 +03:00
Raykov-MS
4d31d39599 . 2026-03-05 18:59:02 +03:00
Raykov-MS
f4f3c43128 Fix unsend msg: 2026-03-05 18:40:42 +03:00
Raykov-MS
aff5d95d74 modify some ideas 2026-03-05 16:43:54 +03:00
Raykov-MS
320cbbb5c3 upd 2026-03-05 12:28:30 +03:00
3 changed files with 193 additions and 38 deletions

View File

@ -43,6 +43,9 @@ python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
- `REMINDER_TIME` - необязательно, по умолчанию `09:00`
- `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned`
- `AUTO_ASSIGN_REVIEWERS_COUNT` - необязательно, по умолчанию `2` (сколько ревьюеров назначать автоматически)
- `AUTO_ASSIGN_EXCLUDED_AUTHORS` - необязательно, CSV логинов Gitea; для этих авторов автоназначение и auto-label на `PR opened` не выполняются
- `BACKEND_USERS` - необязательно, CSV логинов Gitea backend-команды; если автор PR входит в этот список, ревьюеры выбираются только из этого же списка
- `FRONTEND_USERS` - необязательно, CSV логинов Gitea frontend-команды; если автор PR входит в этот список, ревьюеры выбираются только из этого же списка
- `APPROVALS_REQUIRED_FOR_MERGE` - необязательно, по умолчанию `2` (порог approve для сообщения "можно мерджить")
- `NOTIFY_ON_WEEKENDS` - необязательно, по умолчанию `true`; если `false`, бот не отправляет ЛС в субботу и воскресенье (МСК)
- `ALLOW_SELF_ASSIGN` - необязательно, по умолчанию `false` (для локального теста можно `true`)
@ -51,6 +54,8 @@ python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
- `GITEA_COUNT_REPOS` - необязательно, CSV репозиториев `org/repo` для расчета нагрузки (исторический счетчик назначений)
- `BOT_DB_PATH` - необязательно, по умолчанию `botreviewer.sqlite3`
Если автор PR не входит ни в `BACKEND_USERS`, ни в `FRONTEND_USERS`, бот использует общий пул доступных пользователей (как раньше).
## Webhook-эндпоинты
- Обновления Telegram: `POST /telegram/webhook`

207
main.py
View File

@ -42,10 +42,25 @@ TELEGRAM_ALLOWED_USERNAMES = {
if v.strip()
}
GITEA_COUNT_REPOS = {
v.strip()
v.strip().lower()
for v in os.getenv("GITEA_COUNT_REPOS", "").split(",")
if v.strip()
}
AUTO_ASSIGN_EXCLUDED_AUTHORS = {
v.strip().lower()
for v in os.getenv("AUTO_ASSIGN_EXCLUDED_AUTHORS", "").split(",")
if v.strip()
}
BACKEND_USERS = {
v.strip().lower()
for v in os.getenv("BACKEND_USERS", "").split(",")
if v.strip()
}
FRONTEND_USERS = {
v.strip().lower()
for v in os.getenv("FRONTEND_USERS", "").split(",")
if v.strip()
}
NOTIFY_ON_WEEKENDS = os.getenv("NOTIFY_ON_WEEKENDS", "true").lower() == "true"
MSK_TZ = timezone(timedelta(hours=3))
@ -379,21 +394,36 @@ class TelegramClient:
async def send_message(self, chat_id: int, text: str) -> None:
if not self.base_url:
log_warn("[notify] TELEGRAM_BOT_TOKEN пустой, отправка пропущена.")
return
# По настройке можно отключить отправку уведомлений в выходные (МСК).
if msk_now().weekday() >= 5:
if not NOTIFY_ON_WEEKENDS and msk_now().weekday() >= 5:
log_info(f"[notify] Выходной день, отправка пропущена chat_id={chat_id}")
return
delays = (0, 2, 5)
last_exc: Exception | None = None
for attempt_idx, delay_s in enumerate(delays, start=1):
if delay_s > 0:
await asyncio.sleep(delay_s)
try:
async with httpx.AsyncClient(timeout=20) as client:
async with httpx.AsyncClient(timeout=45) as client:
response = await client.post(
f"{self.base_url}/sendMessage",
json={"chat_id": chat_id, "text": text},
)
response.raise_for_status()
log_info(f"[notify] Сообщение отправлено chat_id={chat_id} attempt={attempt_idx}")
return
except Exception as exc:
last_exc = exc
log_warn(
f"[notify] Ошибка отправки chat_id={chat_id} attempt={attempt_idx}/{len(delays)}: "
f"{type(exc).__name__}"
)
# Не роняем обработку webhook, если Telegram API временно недоступен
# или указан неверный токен/чат.
log_error("telegram send_message", exc)
if last_exc is not None:
log_error("telegram send_message", last_exc)
class GiteaClient:
@ -475,7 +505,6 @@ class GiteaClient:
return []
return data
storage = Storage(DB_PATH)
telegram_client = TelegramClient(TELEGRAM_BOT_TOKEN)
gitea_client = GiteaClient(GITEA_BASE_URL, GITEA_TOKEN)
@ -498,10 +527,35 @@ def verify_gitea_signature(raw_body: bytes, signature_header: str | None) -> boo
return hmac.compare_digest(digest, signature_value)
def should_process_repo(repo_full_name: str | None) -> bool:
if not repo_full_name:
return False
if not GITEA_COUNT_REPOS:
return True
return repo_full_name.strip().lower() in GITEA_COUNT_REPOS
def choose_reviewer(candidates: list[RegisteredUser]) -> RegisteredUser:
return random.choice(candidates)
def get_reviewer_group_for_author(author_login: str) -> tuple[set[str] | None, str]:
author_login_norm = author_login.strip().lower()
if author_login_norm in BACKEND_USERS:
return BACKEND_USERS, "backend"
if author_login_norm in FRONTEND_USERS:
return FRONTEND_USERS, "frontend"
return None, ""
def filter_candidates_by_author_group(author_login: str, candidates: list[RegisteredUser]) -> tuple[list[RegisteredUser], str]:
configured_group, group_name = get_reviewer_group_for_author(author_login)
if configured_group is None:
return candidates, ""
filtered = [candidate for candidate in candidates if candidate.gitea_login.strip().lower() in configured_group]
return filtered, group_name
async def build_reviewer_load_counts(candidates: list[RegisteredUser], current_repo_full_name: str) -> dict[str, int]:
_ = current_repo_full_name
return {
@ -574,6 +628,29 @@ def calculate_effective_approvals(reviews: list[dict[str, Any]]) -> int:
return sum(1 for _, state in latest_state_by_login.values() if state == "approved")
def get_pr_author_login(
repo_full_name: str, pr_number: int, pull_request: dict[str, Any] | None = None
) -> str | None:
"""Логин создателя (автора) PR."""
if pull_request:
login = ((pull_request.get("user") or {}).get("login") or "").strip()
if login:
return login
rows = storage.get_assignments_for_pr(repo_full_name, pr_number)
return rows[0]["author_login"] if rows else None
async def get_pr_author_chat_id(
repo_full_name: str, pr_number: int, pull_request: dict[str, Any] | None = None
) -> int | None:
"""Chat_id создателя PR в Telegram (если зарегистрирован)."""
author_login = get_pr_author_login(repo_full_name, pr_number, pull_request)
if not author_login:
return None
user = storage.get_user_by_gitea_login(author_login)
return user.telegram_chat_id if user else None
async def get_pr_participant_chat_ids(
repo_full_name: str,
pr_number: int,
@ -751,6 +828,13 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
author_login = (pull_request.get("user") or {}).get("login")
if not repo_full_name or not pr_number or not author_login:
log_warn("[assign] opened: нет repo/pr/author, событие пропущено.")
return
if author_login.lower() in AUTO_ASSIGN_EXCLUDED_AUTHORS:
log_info(
f"[assign] Автоназначение отключено для автора {author_login} "
f"в {repo_full_name}#{pr_number}."
)
return
# В зависимости от версии Gitea ручные ревьюеры могут приходить как
# в requested_reviewers, так и в reviewers.
@ -778,12 +862,19 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
log_warn("[gitea] Клиент не настроен, автоназначение пропущено.")
return
candidates = storage.get_available_candidates(
all_candidates = storage.get_available_candidates(
author_login=author_login,
today=msk_now().date(),
allow_self_assign=ALLOW_SELF_ASSIGN,
)
candidates, assigned_group_name = filter_candidates_by_author_group(author_login, all_candidates)
if not candidates:
if assigned_group_name:
log_warn(
f"[assign] Нет доступных кандидатов для группы '{assigned_group_name}' "
f"({repo_full_name}#{pr_number}, author={author_login})."
)
else:
log_warn(f"[assign] Нет доступных кандидатов для {repo_full_name}#{pr_number}.")
return
@ -799,10 +890,15 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
log_info(
f"[assign] Автоназначение для {repo_full_name}#{pr_number}. "
f"Кандидаты={len(candidates)}, выбраны={selected_logins}, нагрузки={dict(counts)}"
f"Кандидаты={len(candidates)} из {len(all_candidates)}, "
f"группа={assigned_group_name or 'all'}, выбраны={selected_logins}, нагрузки={dict(counts)}"
)
await gitea_client.assign_reviewers(repo_full_name, int(pr_number), selected_logins)
await gitea_client.add_label(repo_full_name, int(pr_number), AUTO_ASSIGNED_LABEL)
log_info(
f"[assign] Запрошено назначение в Gitea для {repo_full_name}#{pr_number}: {selected_logins}; "
f"label={AUTO_ASSIGNED_LABEL}"
)
for reviewer in selected_reviewers:
storage.add_assignment(
repo_full_name=repo_full_name,
@ -814,6 +910,9 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
reviewer.telegram_chat_id,
f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})",
)
log_info(
f"[notify] opened для {repo_full_name}#{pr_number}, получателей={len(selected_reviewers)}"
)
async def handle_pr_closed(payload: dict[str, Any]) -> None:
@ -823,6 +922,7 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None:
pr_number = pull_request.get("number")
action = payload.get("action")
if not repo_full_name or not pr_number:
log_warn("[notify] closed/merged: нет repo/pr, событие пропущено.")
return
participant_chat_ids = await get_pr_participant_chat_ids(
repo_full_name,
@ -835,6 +935,10 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None:
status_label = "смержен" if is_merged else "закрыт"
title = (pull_request.get("title") or "").strip() or f"#{pr_number}"
msg = f"PR {repo_full_name}#{pr_number} {status_label}: {title}"
log_info(
f"[notify] pr_closed для {repo_full_name}#{pr_number}, "
f"status={status_label}, получателей={len(participant_chat_ids)}"
)
for chat_id in participant_chat_ids:
await telegram_client.send_message(chat_id, msg)
@ -883,11 +987,22 @@ async def handle_issue_comment(payload: dict[str, Any]) -> None:
if not repo_full_name or not pr_number:
return
msg = f"Новый комментарий в PR {repo_full_name}#{pr_number}: {body}"
for chat_id in await get_pr_participant_chat_ids(
recipient_chat_ids = await get_pr_participant_chat_ids(
repo_full_name,
int(pr_number),
exclude_login=commenter_login,
):
)
if not recipient_chat_ids:
log_warn(
f"[notify] issue_comment без получателей для {repo_full_name}#{pr_number}. "
f"commenter={commenter_login}"
)
return
log_info(
f"[notify] issue_comment для {repo_full_name}#{pr_number}, "
f"commenter={commenter_login}, получателей={len(recipient_chat_ids)}"
)
for chat_id in recipient_chat_ids:
await telegram_client.send_message(chat_id, msg)
@ -903,23 +1018,35 @@ async def handle_pull_request_comment(payload: dict[str, Any]) -> None:
if not repo_full_name or not pr_number or not body:
return
msg = f"Новый комментарий в PR {repo_full_name}#{pr_number}: {body}"
for chat_id in await get_pr_participant_chat_ids(
recipient_chat_ids = await get_pr_participant_chat_ids(
repo_full_name,
int(pr_number),
pull_request=pull_request,
exclude_login=commenter_login,
):
)
if not recipient_chat_ids:
log_warn(
f"[notify] pull_request_comment без получателей для {repo_full_name}#{pr_number}. "
f"commenter={commenter_login}"
)
return
log_info(
f"[notify] pull_request_comment для {repo_full_name}#{pr_number}, "
f"commenter={commenter_login}, получателей={len(recipient_chat_ids)}"
)
for chat_id in recipient_chat_ids:
await telegram_client.send_message(chat_id, msg)
async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
"""Изменение статуса ревью (approved / request changes) — уведомляем участников."""
"""Изменение статуса ревью (approved / request changes) — уведомляем только создателя PR."""
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:
log_warn("[notify] reviewed: нет repo/pr, событие пропущено.")
return
state = (review.get("state") or "").lower()
reviewer_login = (
@ -937,15 +1064,19 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
msg = f"PR {repo_full_name}#{pr_number}: ревью {status_text} ({reviewer_login})."
else:
msg = f"PR {repo_full_name}#{pr_number}: ревью {status_text}."
for chat_id in await get_pr_participant_chat_ids(
repo_full_name,
int(pr_number),
pull_request=pull_request,
exclude_login=reviewer_login,
):
await telegram_client.send_message(chat_id, msg)
author_chat_id = await get_pr_author_chat_id(repo_full_name, int(pr_number), pull_request)
if author_chat_id is not None:
log_info(
f"[notify] reviewed для {repo_full_name}#{pr_number}, "
f"state={state or 'unknown'}, reviewer={reviewer_login or '-'}, получателей=1"
)
await telegram_client.send_message(author_chat_id, msg)
else:
log_warn(
f"[notify] ревью для {repo_full_name}#{pr_number}: автор не зарегистрирован в боте."
)
# Доп. нотификация, когда набран порог approve для merge.
# Уведомление «можно мерджить» — только создателю PR.
if not gitea_client.enabled:
return
reviews = await gitea_client.list_pull_request_reviews(repo_full_name, int(pr_number))
@ -954,21 +1085,18 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
already_notified = storage.get_kv(merge_ready_key) == "1"
if approvals_count >= APPROVALS_REQUIRED_FOR_MERGE:
if not already_notified:
if not already_notified and author_chat_id is not None:
merge_msg = (
f"PR {repo_full_name}#{pr_number} набрал {approvals_count} approve "
f"(порог {APPROVALS_REQUIRED_FOR_MERGE}) — можно мерджить."
)
for chat_id in await get_pr_participant_chat_ids(
repo_full_name,
int(pr_number),
pull_request=pull_request,
):
await telegram_client.send_message(chat_id, merge_msg)
await telegram_client.send_message(author_chat_id, merge_msg)
storage.set_kv(merge_ready_key, "1")
log_info(
f"[notify] merge_ready для {repo_full_name}#{pr_number}, "
f"approvals={approvals_count}, threshold={APPROVALS_REQUIRED_FOR_MERGE}"
)
else:
# Если approvals опустились ниже порога (например, после request changes),
# разрешаем повторную нотификацию при следующем достижении порога.
if already_notified:
storage.delete_kv(merge_ready_key)
@ -1067,13 +1195,34 @@ async def gitea_webhook(
except json.JSONDecodeError as exc:
log_error("gitea webhook invalid json", exc)
raise HTTPException(status_code=400, detail="Некорректный JSON") from exc
repo_full_name = ((payload.get("repository") or {}).get("full_name") or "").strip()
if repo_full_name and not should_process_repo(repo_full_name):
log_info(f"[gitea] repo={repo_full_name} не входит в GITEA_COUNT_REPOS, событие пропущено.")
return {"ok": "ignored"}
log_info(f"[gitea] event={x_gitea_event} action={payload.get('action')}")
log_info(
"[route] "
f"event={x_gitea_event} action={payload.get('action')} "
f"has_comment={bool(payload.get('comment'))} "
f"has_review={bool(payload.get('review'))} "
f"is_pull={bool(payload.get('is_pull'))}"
)
try:
if x_gitea_event == "issue_comment":
log_info("[route] -> handle_issue_comment")
await handle_issue_comment(payload)
return {"ok": "true"}
if x_gitea_event == "pull_request_comment":
if payload.get("comment"):
log_info("[route] -> handle_pull_request_comment (comment)")
await handle_pull_request_comment(payload)
elif payload.get("review"):
log_info("[route] -> handle_pr_reviewed (from pull_request_comment)")
await handle_pr_reviewed(payload)
return {"ok": "true"}
if x_gitea_event in ("pull_request_review", "pull_request_approved", "pull_request_rejected") and payload.get("review"):
await handle_pr_reviewed(payload)
return {"ok": "true"}

View File

@ -2,3 +2,4 @@ fastapi
uvicorn
httpx
dotenv
socksio