diff --git a/README.md b/README.md index d1785d2..ae9d630 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env - `GITEA_WEBHOOK_SECRET` - обязательно, общий секрет webhook от Gitea - `REMINDER_TIME` - необязательно, по умолчанию `09:00` - `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned` +- `AUTO_ASSIGN_REVIEWERS_COUNT` - необязательно, по умолчанию `2` (сколько ревьюеров назначать автоматически) - `ALLOW_SELF_ASSIGN` - необязательно, по умолчанию `false` (для локального теста можно `true`) - `TELEGRAM_ALLOWED_CHAT_IDS` - необязательно, CSV списка `chat_id`, которым можно управлять ботом - `TELEGRAM_ALLOWED_USERNAMES` - необязательно, CSV списка Telegram username (без `@`) diff --git a/main.py b/main.py index 0d88d1b..0f869ad 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,4 @@ import asyncio -from collections import defaultdict import hashlib import hmac import json @@ -22,6 +21,10 @@ GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") GITEA_WEBHOOK_SECRET = os.getenv("GITEA_WEBHOOK_SECRET", "") REMINDER_TIME = os.getenv("REMINDER_TIME", "09:00") # Формат ЧЧ:ММ 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" TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "") TELEGRAM_ALLOWED_CHAT_IDS = { @@ -238,6 +241,14 @@ class Storage: ).fetchone() 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( self, repo_full_name: str, @@ -373,14 +384,16 @@ class GiteaClient: def enabled(self) -> bool: 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: raise RuntimeError("Gitea клиент не настроен") + if not reviewer_logins: + return async with httpx.AsyncClient(timeout=20) as client: response = await client.post( f"{self.base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/requested_reviewers", headers={"Authorization": f"token {self.token}"}, - json={"reviewers": [reviewer_login]}, + json={"reviewers": reviewer_logins}, ) 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: - counts = { - candidate.gitea_login: storage.get_open_reviews_count(candidate.gitea_login) + return random.choice(candidates) + + +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 } - 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: - candidate_logins = {candidate.gitea_login for candidate in candidates} - repos_to_scan = set(storage.get_known_repositories()) | GITEA_COUNT_REPOS | {current_repo_full_name} - counts: dict[str, int] = defaultdict(int) +async def choose_reviewers_with_remote_load( + candidates: list[RegisteredUser], + current_repo_full_name: str, + 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: - return choose_reviewer(candidates) + while remaining and len(selected) < target_count: + 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: - 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) + return selected, counts 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: pull_request = payload.get("pull_request") or {} - requested_reviewers = pull_request.get("requested_reviewers") or [] repository = payload.get("repository") or {} 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: return - requested_reviewer_logins = [ - reviewer.get("login") - for reviewer in requested_reviewers - if (reviewer or {}).get("login") - ] - if requested_reviewer_logins: - for reviewer_login in requested_reviewer_logins: + # В зависимости от версии Gitea ручные ревьюеры могут приходить как + # в requested_reviewers, так и в reviewers. + existing_reviewer_logins = extract_reviewer_logins_from_pull_request(pull_request) + 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 existing_reviewer_logins: + for reviewer_login in existing_reviewer_logins: storage.add_assignment( repo_full_name=repo_full_name, pr_number=int(pr_number), @@ -713,6 +717,10 @@ async def handle_pr_opened(payload: dict[str, Any]) -> None: reviewer_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 if not gitea_client.enabled: 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}.") return - reviewer = await choose_reviewer_with_remote_load(candidates, repo_full_name) - await gitea_client.assign_reviewer(repo_full_name, int(pr_number), reviewer.gitea_login) - await gitea_client.add_label(repo_full_name, int(pr_number), AUTO_ASSIGNED_LABEL) - storage.add_assignment( - repo_full_name=repo_full_name, - pr_number=int(pr_number), - author_login=author_login, - reviewer_login=reviewer.gitea_login, + selected_reviewers, counts = await choose_reviewers_with_remote_load( + candidates, + repo_full_name, + AUTO_ASSIGN_REVIEWERS_COUNT, ) + 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( - reviewer.telegram_chat_id, - f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})", + log_info( + f"[assign] Автоназначение для {repo_full_name}#{pr_number}. " + 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: @@ -757,7 +778,8 @@ async def handle_pr_closed(payload: dict[str, Any]) -> None: pull_request=pull_request, ) 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}" msg = f"PR {repo_full_name}#{pr_number} {status_label}: {title}" 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) +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: """Изменение статуса ревью (approved / request changes) — уведомляем участников.""" pull_request = payload.get("pull_request") or {} @@ -924,6 +967,7 @@ 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 + log_info(f"[gitea] event={x_gitea_event} action={payload.get('action')}") try: if x_gitea_event == "issue_comment": @@ -938,6 +982,8 @@ async def gitea_webhook( action = payload.get("action") if action == "opened": await handle_pr_opened(payload) + elif action in ("commented", "comment"): + await handle_pull_request_comment(payload) elif action in ("closed", "merged"): await handle_pr_closed(payload) elif action == "synchronize":