Сделан весь функционал бота. Осталось проверить уведомление по достижению 2 апрувов и запроса изменений

This commit is contained in:
Raykov-MS 2026-02-28 21:43:41 +03:00
parent 42186a3d73
commit 913d245bdd
2 changed files with 96 additions and 12 deletions

View File

@ -1,23 +1,36 @@
# CodeReview Bot (MVP)
# CodeReview Bot
Реализовано в текущем MVP:
Реализовано:
- Регистрация в Telegram: `/register <git_email> <gitea_login>`
- Период недоступности: `/away <YYYY-MM-DD> <YYYY-MM-DD>`
- Настройка времени напоминания: `/reminder HH:MM`
- Просмотр открытых ревью: `/myreviews`
- Webhook Gitea по PR:
- при создании PR без ревьюера бот назначает доступного участника с минимальной нагрузкой
- ставит метку `auto-assigned` (best effort)
- отправляет уведомление назначенному ревьюеру в личные сообщения Telegram
- Ежедневное напоминание в общее время `REMINDER_TIME` (по умолчанию `09:00`)
- при создании PR без ревьюера бот назначает доступных участников
- количество автоназначаемых ревьюеров задается `AUTO_ASSIGN_REVIEWERS_COUNT` (по умолчанию `2`)
- при ручном назначении ревьюеров автоназначение пропускается
- ставится метка `auto-assigned` (для автоназначения)
- отправляются уведомления в Telegram ЛС
- Уведомления по событиям PR:
- новое назначение ревьюера;
- новый комментарий;
- новые коммиты в открытом PR (`synchronize`/`synchronized`);
- статус ревью (`approved` / `request changes`);
- закрытие/merge PR;
- достижение порога approve: "можно мерджить" (`APPROVALS_REQUIRED_FOR_MERGE`).
- Ежедневное напоминание по открытым ревью в настроенное время.
- Техническая проверка сервиса: `GET /health`
Время в боте (напоминания и проверка `away`) используется по МСК (UTC+3).
## Запуск
```bash
python -m venv .venv
. .venv/Scripts/activate
pip install -r requirements.txt
uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
```
## Переменные окружения
@ -30,10 +43,11 @@ 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` (сколько ревьюеров назначать автоматически)
- `APPROVALS_REQUIRED_FOR_MERGE` - необязательно, по умолчанию `2` (порог approve для сообщения "можно мерджить")
- `ALLOW_SELF_ASSIGN` - необязательно, по умолчанию `false` (для локального теста можно `true`)
- `TELEGRAM_ALLOWED_CHAT_IDS` - необязательно, CSV списка `chat_id`, которым можно управлять ботом
- `TELEGRAM_ALLOWED_USERNAMES` - необязательно, CSV списка Telegram username (без `@`)
- `GITEA_COUNT_REPOS` - необязательно, CSV репозиториев `org/repo` для расчета нагрузки ревьюеров
- `GITEA_COUNT_REPOS` - необязательно, CSV репозиториев `org/repo` для расчета нагрузки (исторический счетчик назначений)
- `BOT_DB_PATH` - необязательно, по умолчанию `botreviewer.sqlite3`
## Webhook-эндпоинты
@ -42,7 +56,10 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
- Webhook Gitea: `POST /gitea/webhook`
Для Telegram укажите URL вашего сервиса с путем `/telegram/webhook` и `secret_token`.
Для Gitea включите события `pull_request` и задайте общий секрет.
Для Gitea задайте общий secret и включите события:
- `pull_request`
- `issue_comment`
- `pull_request_review`
## Локальная разработка через Tuna
@ -51,7 +68,7 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
1. Запустите бота:
```bash
uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
python -m uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
```
2. Поднимите туннель в Tuna(https://my.tuna.am/) на локальный порт `8080` и получите публичный URL, например:
@ -71,7 +88,7 @@ https://example.ru.tuna.am/health
- Method: `POST`
- Content Type: `application/json`
- Secret: тот же, что в `GITEA_WEBHOOK_SECRET`
- Events: `Pull Request` (Custom Events)
- Events: `Pull Request`, `Issue Comment`, `Pull Request Review`
5. Настройте webhook в Telegram:

69
main.py
View File

@ -25,6 +25,10 @@ try:
AUTO_ASSIGN_REVIEWERS_COUNT = max(1, int(os.getenv("AUTO_ASSIGN_REVIEWERS_COUNT", "2")))
except ValueError:
AUTO_ASSIGN_REVIEWERS_COUNT = 2
try:
APPROVALS_REQUIRED_FOR_MERGE = max(1, int(os.getenv("APPROVALS_REQUIRED_FOR_MERGE", "2")))
except ValueError:
APPROVALS_REQUIRED_FOR_MERGE = 2
ALLOW_SELF_ASSIGN = os.getenv("ALLOW_SELF_ASSIGN", "false").lower() == "true"
TELEGRAM_WEBHOOK_SECRET = os.getenv("TELEGRAM_WEBHOOK_SECRET", "")
TELEGRAM_ALLOWED_CHAT_IDS = {
@ -335,6 +339,10 @@ class Storage:
(key, value),
)
def delete_kv(self, key: str) -> None:
with self._connect() as conn:
conn.execute("DELETE FROM kv WHERE key = ?", (key,))
def get_reminder_time(self, telegram_chat_id: int) -> str | None:
return self.get_kv(f"reminder_time:{telegram_chat_id}")
@ -444,6 +452,21 @@ class GiteaClient:
return []
return data
async def list_pull_request_reviews(self, repo_full_name: str, pr_number: int) -> list[dict[str, Any]]:
if not self.enabled:
return []
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
f"{self.base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/reviews",
headers={"Authorization": f"token {self.token}"},
)
if response.status_code >= 400:
return []
data = response.json()
if not isinstance(data, list):
return []
return data
storage = Storage(DB_PATH)
telegram_client = TelegramClient(TELEGRAM_BOT_TOKEN)
@ -527,6 +550,22 @@ def extract_reviewer_logins_from_pull_request(pull_request: dict[str, Any] | Non
return logins
def calculate_effective_approvals(reviews: list[dict[str, Any]]) -> int:
"""Считает актуальное число approve с учетом последнего состояния каждого ревьюера."""
latest_state_by_login: dict[str, tuple[int, str]] = {}
for review in reviews:
user = review.get("user") or {}
login = (user.get("login") or "").strip()
if not login:
continue
state = str(review.get("state") or "").lower()
review_id = int(review.get("id") or 0)
prev = latest_state_by_login.get(login)
if prev is None or review_id >= prev[0]:
latest_state_by_login[login] = (review_id, state)
return sum(1 for _, state in latest_state_by_login.values() if state == "approved")
async def get_pr_participant_chat_ids(
repo_full_name: str,
pr_number: int,
@ -783,6 +822,7 @@ 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))
storage.delete_kv(f"merge_ready_notified:{repo_full_name}:{int(pr_number)}")
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}"
@ -876,7 +916,7 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
state = (review.get("state") or "").lower()
reviewer_login = (review.get("user") or {}).get("login") or ""
if state == "approved":
status_text = "одобрен"
status_text = "одобрен(апрувнут)"
elif state in ("request_changes", "request changes"):
status_text = "запрошены правки"
else:
@ -890,6 +930,33 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
):
await telegram_client.send_message(chat_id, msg)
# Доп. нотификация, когда набран порог approve для merge.
if not gitea_client.enabled:
return
reviews = await gitea_client.list_pull_request_reviews(repo_full_name, int(pr_number))
approvals_count = calculate_effective_approvals(reviews)
merge_ready_key = f"merge_ready_notified:{repo_full_name}:{int(pr_number)}"
already_notified = storage.get_kv(merge_ready_key) == "1"
if approvals_count >= APPROVALS_REQUIRED_FOR_MERGE:
if not already_notified:
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)
storage.set_kv(merge_ready_key, "1")
else:
# Если approvals опустились ниже порога (например, после request changes),
# разрешаем повторную нотификацию при следующем достижении порога.
if already_notified:
storage.delete_kv(merge_ready_key)
async def reminder_loop() -> None:
while True: