This commit is contained in:
Raykov-MS 2026-02-28 20:58:07 +03:00
parent cb4bb4a399
commit e07a922f23
2 changed files with 102 additions and 55 deletions

View File

@ -29,6 +29,7 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
- `GITEA_WEBHOOK_SECRET` - обязательно, общий секрет webhook от Gitea - `GITEA_WEBHOOK_SECRET` - обязательно, общий секрет webhook от Gitea
- `REMINDER_TIME` - необязательно, по умолчанию `09:00` - `REMINDER_TIME` - необязательно, по умолчанию `09:00`
- `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned` - `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned`
- `AUTO_ASSIGN_REVIEWERS_COUNT` - необязательно, по умолчанию `2` (сколько ревьюеров назначать автоматически)
- `ALLOW_SELF_ASSIGN` - необязательно, по умолчанию `false` (для локального теста можно `true`) - `ALLOW_SELF_ASSIGN` - необязательно, по умолчанию `false` (для локального теста можно `true`)
- `TELEGRAM_ALLOWED_CHAT_IDS` - необязательно, CSV списка `chat_id`, которым можно управлять ботом - `TELEGRAM_ALLOWED_CHAT_IDS` - необязательно, CSV списка `chat_id`, которым можно управлять ботом
- `TELEGRAM_ALLOWED_USERNAMES` - необязательно, CSV списка Telegram username (без `@`) - `TELEGRAM_ALLOWED_USERNAMES` - необязательно, CSV списка Telegram username (без `@`)

156
main.py
View File

@ -1,5 +1,4 @@
import asyncio import asyncio
from collections import defaultdict
import hashlib import hashlib
import hmac import hmac
import json import json
@ -22,6 +21,10 @@ GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
GITEA_WEBHOOK_SECRET = os.getenv("GITEA_WEBHOOK_SECRET", "") GITEA_WEBHOOK_SECRET = os.getenv("GITEA_WEBHOOK_SECRET", "")
REMINDER_TIME = os.getenv("REMINDER_TIME", "09:00") # Формат ЧЧ:ММ REMINDER_TIME = os.getenv("REMINDER_TIME", "09:00") # Формат ЧЧ:ММ
AUTO_ASSIGNED_LABEL = os.getenv("AUTO_ASSIGNED_LABEL", "auto-assigned") AUTO_ASSIGNED_LABEL = os.getenv("AUTO_ASSIGNED_LABEL", "auto-assigned")
try:
AUTO_ASSIGN_REVIEWERS_COUNT = max(1, int(os.getenv("AUTO_ASSIGN_REVIEWERS_COUNT", "2")))
except ValueError:
AUTO_ASSIGN_REVIEWERS_COUNT = 2
ALLOW_SELF_ASSIGN = os.getenv("ALLOW_SELF_ASSIGN", "false").lower() == "true" ALLOW_SELF_ASSIGN = os.getenv("ALLOW_SELF_ASSIGN", "false").lower() == "true"
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "") TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "")
TELEGRAM_ALLOWED_CHAT_IDS = { TELEGRAM_ALLOWED_CHAT_IDS = {
@ -238,6 +241,14 @@ class Storage:
).fetchone() ).fetchone()
return int(row["c"]) return int(row["c"])
def get_total_reviews_count(self, reviewer_login: str) -> int:
with self._connect() as conn:
row = conn.execute(
"SELECT COUNT(*) as c FROM assignments WHERE reviewer_login = ?",
(reviewer_login,),
).fetchone()
return int(row["c"])
def add_assignment( def add_assignment(
self, self,
repo_full_name: str, repo_full_name: str,
@ -373,14 +384,16 @@ class GiteaClient:
def enabled(self) -> bool: def enabled(self) -> bool:
return bool(self.base_url and self.token) return bool(self.base_url and self.token)
async def assign_reviewer(self, repo_full_name: str, pr_number: int, reviewer_login: str) -> None: async def assign_reviewers(self, repo_full_name: str, pr_number: int, reviewer_logins: list[str]) -> None:
if not self.enabled: if not self.enabled:
raise RuntimeError("Gitea клиент не настроен") raise RuntimeError("Gitea клиент не настроен")
if not reviewer_logins:
return
async with httpx.AsyncClient(timeout=20) as client: async with httpx.AsyncClient(timeout=20) as client:
response = await client.post( response = await client.post(
f"{self.base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/requested_reviewers", f"{self.base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/requested_reviewers",
headers={"Authorization": f"token {self.token}"}, headers={"Authorization": f"token {self.token}"},
json={"reviewers": [reviewer_login]}, json={"reviewers": reviewer_logins},
) )
response.raise_for_status() response.raise_for_status()
@ -450,44 +463,36 @@ def verify_gitea_signature(raw_body: bytes, signature_header: str | None) -> boo
def choose_reviewer(candidates: list[RegisteredUser]) -> RegisteredUser: def choose_reviewer(candidates: list[RegisteredUser]) -> RegisteredUser:
counts = { return random.choice(candidates)
candidate.gitea_login: storage.get_open_reviews_count(candidate.gitea_login)
async def build_reviewer_load_counts(candidates: list[RegisteredUser], current_repo_full_name: str) -> dict[str, int]:
_ = current_repo_full_name
return {
candidate.gitea_login: storage.get_total_reviews_count(candidate.gitea_login)
for candidate in candidates for candidate in candidates
} }
min_count = min(counts.values())
shortlist = [c for c in candidates if counts[c.gitea_login] == min_count]
return random.choice(shortlist)
async def choose_reviewer_with_remote_load(candidates: list[RegisteredUser], current_repo_full_name: str) -> RegisteredUser: async def choose_reviewers_with_remote_load(
candidate_logins = {candidate.gitea_login for candidate in candidates} candidates: list[RegisteredUser],
repos_to_scan = set(storage.get_known_repositories()) | GITEA_COUNT_REPOS | {current_repo_full_name} current_repo_full_name: str,
counts: dict[str, int] = defaultdict(int) reviewers_count: int,
) -> tuple[list[RegisteredUser], dict[str, int]]:
counts = await build_reviewer_load_counts(candidates, current_repo_full_name)
remaining = list(candidates)
selected: list[RegisteredUser] = []
target_count = max(1, min(reviewers_count, len(candidates)))
if not gitea_client.enabled: while remaining and len(selected) < target_count:
return choose_reviewer(candidates) min_count = min(counts[candidate.gitea_login] for candidate in remaining)
shortlist = [candidate for candidate in remaining if counts[candidate.gitea_login] == min_count]
chosen = choose_reviewer(shortlist)
selected.append(chosen)
remaining = [candidate for candidate in remaining if candidate.gitea_login != chosen.gitea_login]
counts[chosen.gitea_login] += 1
for repo in repos_to_scan: return selected, counts
page = 1
while True:
pulls = await gitea_client.list_open_pull_requests(repo, page=page, limit=50)
if not pulls:
break
for pull in pulls:
reviewer_logins = extract_reviewer_logins_from_pull_request(pull)
for login in reviewer_logins:
if login in candidate_logins:
counts[login] += 1
if len(pulls) < 50:
break
page += 1
for candidate in candidates:
counts[candidate.gitea_login] += 0
min_count = min(counts.values())
shortlist = [c for c in candidates if counts[c.gitea_login] == min_count]
return random.choice(shortlist)
def extract_logins_from_pull_request(pull_request: dict[str, Any] | None) -> set[str]: def extract_logins_from_pull_request(pull_request: dict[str, Any] | None) -> set[str]:
@ -687,7 +692,6 @@ async def handle_telegram_command(update: dict[str, Any]) -> None:
async def handle_pr_opened(payload: dict[str, Any]) -> None: async def handle_pr_opened(payload: dict[str, Any]) -> None:
pull_request = payload.get("pull_request") or {} pull_request = payload.get("pull_request") or {}
requested_reviewers = pull_request.get("requested_reviewers") or []
repository = payload.get("repository") or {} repository = payload.get("repository") or {}
repo_full_name = repository.get("full_name") repo_full_name = repository.get("full_name")
@ -696,13 +700,13 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
if not repo_full_name or not pr_number or not author_login: if not repo_full_name or not pr_number or not author_login:
return return
requested_reviewer_logins = [ # В зависимости от версии Gitea ручные ревьюеры могут приходить как
reviewer.get("login") # в requested_reviewers, так и в reviewers.
for reviewer in requested_reviewers existing_reviewer_logins = extract_reviewer_logins_from_pull_request(pull_request)
if (reviewer or {}).get("login") remote_pull = await gitea_client.get_pull_request(repo_full_name, int(pr_number)) if gitea_client.enabled else None
] existing_reviewer_logins.update(extract_reviewer_logins_from_pull_request(remote_pull))
if requested_reviewer_logins: if existing_reviewer_logins:
for reviewer_login in requested_reviewer_logins: for reviewer_login in existing_reviewer_logins:
storage.add_assignment( storage.add_assignment(
repo_full_name=repo_full_name, repo_full_name=repo_full_name,
pr_number=int(pr_number), pr_number=int(pr_number),
@ -713,6 +717,10 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
reviewer_login, reviewer_login,
f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})", f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})",
) )
log_info(
f"[assign] Ручные ревьюеры уже заданы для {repo_full_name}#{pr_number}: "
f"{', '.join(sorted(existing_reviewer_logins))}. Автоназначение пропущено."
)
return return
if not gitea_client.enabled: if not gitea_client.enabled:
log_warn("[gitea] Клиент не настроен, автоназначение пропущено.") log_warn("[gitea] Клиент не настроен, автоназначение пропущено.")
@ -727,20 +735,33 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None:
log_warn(f"[assign] Нет доступных кандидатов для {repo_full_name}#{pr_number}.") log_warn(f"[assign] Нет доступных кандидатов для {repo_full_name}#{pr_number}.")
return return
reviewer = await choose_reviewer_with_remote_load(candidates, repo_full_name) selected_reviewers, counts = await choose_reviewers_with_remote_load(
await gitea_client.assign_reviewer(repo_full_name, int(pr_number), reviewer.gitea_login) candidates,
await gitea_client.add_label(repo_full_name, int(pr_number), AUTO_ASSIGNED_LABEL) repo_full_name,
storage.add_assignment( AUTO_ASSIGN_REVIEWERS_COUNT,
repo_full_name=repo_full_name,
pr_number=int(pr_number),
author_login=author_login,
reviewer_login=reviewer.gitea_login,
) )
selected_logins = [reviewer.gitea_login for reviewer in selected_reviewers]
if not selected_reviewers:
log_warn(f"[assign] Не удалось выбрать ревьюеров для {repo_full_name}#{pr_number}.")
return
await telegram_client.send_message( log_info(
reviewer.telegram_chat_id, f"[assign] Автоназначение для {repo_full_name}#{pr_number}. "
f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})", f"Кандидаты={len(candidates)}, выбраны={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)
for reviewer in selected_reviewers:
storage.add_assignment(
repo_full_name=repo_full_name,
pr_number=int(pr_number),
author_login=author_login,
reviewer_login=reviewer.gitea_login,
)
await telegram_client.send_message(
reviewer.telegram_chat_id,
f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})",
)
async def handle_pr_closed(payload: dict[str, Any]) -> None: async def handle_pr_closed(payload: dict[str, Any]) -> None:
@ -757,7 +778,8 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None:
pull_request=pull_request, pull_request=pull_request,
) )
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 "закрыт" is_merged = action == "merged" or bool(pull_request.get("merged"))
status_label = "смержен" if is_merged else "закрыт"
title = (pull_request.get("title") or "").strip() or f"#{pr_number}" title = (pull_request.get("title") or "").strip() or f"#{pr_number}"
msg = f"PR {repo_full_name}#{pr_number} {status_label}: {title}" msg = f"PR {repo_full_name}#{pr_number} {status_label}: {title}"
for chat_id in participant_chat_ids: for chat_id in participant_chat_ids:
@ -805,6 +827,27 @@ async def handle_issue_comment(payload: dict[str, Any]) -> None:
await telegram_client.send_message(chat_id, msg) await telegram_client.send_message(chat_id, msg)
async def handle_pull_request_comment(payload: dict[str, Any]) -> None:
"""Комментарий в PR из pull_request-события — уведомляем участников, кроме автора комментария."""
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")
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 or not body:
return
msg = f"Новый комментарий в PR {repo_full_name}#{pr_number}: {body}"
for chat_id in await get_pr_participant_chat_ids(
repo_full_name,
int(pr_number),
pull_request=pull_request,
exclude_login=commenter_login,
):
await telegram_client.send_message(chat_id, msg)
async def handle_pr_reviewed(payload: dict[str, Any]) -> None: async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
"""Изменение статуса ревью (approved / request changes) — уведомляем участников.""" """Изменение статуса ревью (approved / request changes) — уведомляем участников."""
pull_request = payload.get("pull_request") or {} pull_request = payload.get("pull_request") or {}
@ -924,6 +967,7 @@ async def gitea_webhook(
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
log_error("gitea webhook invalid json", exc) log_error("gitea webhook invalid json", exc)
raise HTTPException(status_code=400, detail="Некорректный JSON") from exc raise HTTPException(status_code=400, detail="Некорректный JSON") from exc
log_info(f"[gitea] event={x_gitea_event} action={payload.get('action')}")
try: try:
if x_gitea_event == "issue_comment": if x_gitea_event == "issue_comment":
@ -938,6 +982,8 @@ async def gitea_webhook(
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 ("commented", "comment"):
await handle_pull_request_comment(payload)
elif action in ("closed", "merged"): elif action in ("closed", "merged"):
await handle_pr_closed(payload) await handle_pr_closed(payload)
elif action == "synchronize": elif action == "synchronize":