Сделан весь функционал бота. Осталось проверить уведомление по достижению 2 апрувов и запроса изменений
This commit is contained in:
parent
42186a3d73
commit
913d245bdd
39
README.md
39
README.md
@ -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
69
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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user