Compare commits
10 Commits
206bb9fec0
...
bbac9918e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbac9918e1 | ||
|
|
3dcf947c68 | ||
|
|
d90c0f40f4 | ||
|
|
04cf58dd1f | ||
|
|
91338ca40f | ||
|
|
cbec9eddb5 | ||
|
|
4d31d39599 | ||
|
|
f4f3c43128 | ||
|
|
aff5d95d74 | ||
|
|
320cbbb5c3 |
@ -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`
|
||||
|
||||
225
main.py
225
main.py
@ -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
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": text},
|
||||
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=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__}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as exc:
|
||||
# Не роняем обработку webhook, если Telegram API временно недоступен
|
||||
# или указан неверный токен/чат.
|
||||
log_error("telegram send_message", exc)
|
||||
# Не роняем обработку webhook, если Telegram API временно недоступен
|
||||
# или указан неверный токен/чат.
|
||||
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,13 +862,20 @@ 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:
|
||||
log_warn(f"[assign] Нет доступных кандидатов для {repo_full_name}#{pr_number}.")
|
||||
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
|
||||
|
||||
selected_reviewers, counts = await choose_reviewers_with_remote_load(
|
||||
@ -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"}
|
||||
|
||||
@ -2,3 +2,4 @@ fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
dotenv
|
||||
socksio
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user