From 913d245bdd842954320f762f3259ff4e71faf1e7 Mon Sep 17 00:00:00 2001 From: Raykov-MS Date: Sat, 28 Feb 2026 21:43:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=20=D0=B2?= =?UTF-8?q?=D0=B5=D1=81=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=20=D0=B1=D0=BE=D1=82=D0=B0.=20=D0=9E=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D1=81=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D1=82=D1=8C=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8E=202=20?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D1=83=D0=B2=D0=BE=D0=B2=20=D0=B8=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 ++++++++++++++++++++++--------- main.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ae9d630..40baa23 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,36 @@ -# CodeReview Bot (MVP) +# CodeReview Bot -Реализовано в текущем MVP: +Реализовано: - Регистрация в Telegram: `/register ` - Период недоступности: `/away ` +- Настройка времени напоминания: `/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: diff --git a/main.py b/main.py index 334796f..793448a 100644 --- a/main.py +++ b/main.py @@ -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: