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
|
- `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 (без `@`)
|
||||||
|
|||||||
140
main.py
140
main.py
@ -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,16 +735,29 @@ 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,
|
||||||
|
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)
|
await gitea_client.add_label(repo_full_name, int(pr_number), AUTO_ASSIGNED_LABEL)
|
||||||
|
for reviewer in selected_reviewers:
|
||||||
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),
|
||||||
author_login=author_login,
|
author_login=author_login,
|
||||||
reviewer_login=reviewer.gitea_login,
|
reviewer_login=reviewer.gitea_login,
|
||||||
)
|
)
|
||||||
|
|
||||||
await telegram_client.send_message(
|
await telegram_client.send_message(
|
||||||
reviewer.telegram_chat_id,
|
reviewer.telegram_chat_id,
|
||||||
f"Назначено новое ревью: {repo_full_name}#{pr_number} (автор: {author_login})",
|
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,
|
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":
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user