E2E2
This commit is contained in:
parent
cb4bb4a399
commit
e07a922f23
@ -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 (без `@`)
|
||||
|
||||
140
main.py
140
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,16 +735,29 @@ 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)
|
||||
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
|
||||
|
||||
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})",
|
||||
@ -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":
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user