Сделан весь функционал бота. Осталось проверить уведомление по достижению 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>`
|
- Регистрация в Telegram: `/register <git_email> <gitea_login>`
|
||||||
- Период недоступности: `/away <YYYY-MM-DD> <YYYY-MM-DD>`
|
- Период недоступности: `/away <YYYY-MM-DD> <YYYY-MM-DD>`
|
||||||
|
- Настройка времени напоминания: `/reminder HH:MM`
|
||||||
|
- Просмотр открытых ревью: `/myreviews`
|
||||||
- Webhook Gitea по PR:
|
- Webhook Gitea по PR:
|
||||||
- при создании PR без ревьюера бот назначает доступного участника с минимальной нагрузкой
|
- при создании PR без ревьюера бот назначает доступных участников
|
||||||
- ставит метку `auto-assigned` (best effort)
|
- количество автоназначаемых ревьюеров задается `AUTO_ASSIGN_REVIEWERS_COUNT` (по умолчанию `2`)
|
||||||
- отправляет уведомление назначенному ревьюеру в личные сообщения Telegram
|
- при ручном назначении ревьюеров автоназначение пропускается
|
||||||
- Ежедневное напоминание в общее время `REMINDER_TIME` (по умолчанию `09:00`)
|
- ставится метка `auto-assigned` (для автоназначения)
|
||||||
|
- отправляются уведомления в Telegram ЛС
|
||||||
|
- Уведомления по событиям PR:
|
||||||
|
- новое назначение ревьюера;
|
||||||
|
- новый комментарий;
|
||||||
|
- новые коммиты в открытом PR (`synchronize`/`synchronized`);
|
||||||
|
- статус ревью (`approved` / `request changes`);
|
||||||
|
- закрытие/merge PR;
|
||||||
|
- достижение порога approve: "можно мерджить" (`APPROVALS_REQUIRED_FOR_MERGE`).
|
||||||
|
- Ежедневное напоминание по открытым ревью в настроенное время.
|
||||||
- Техническая проверка сервиса: `GET /health`
|
- Техническая проверка сервиса: `GET /health`
|
||||||
|
|
||||||
|
Время в боте (напоминания и проверка `away`) используется по МСК (UTC+3).
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
. .venv/Scripts/activate
|
. .venv/Scripts/activate
|
||||||
pip install -r requirements.txt
|
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`
|
- `REMINDER_TIME` - необязательно, по умолчанию `09:00`
|
||||||
- `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned`
|
- `AUTO_ASSIGNED_LABEL` - необязательно, по умолчанию `auto-assigned`
|
||||||
- `AUTO_ASSIGN_REVIEWERS_COUNT` - необязательно, по умолчанию `2` (сколько ревьюеров назначать автоматически)
|
- `AUTO_ASSIGN_REVIEWERS_COUNT` - необязательно, по умолчанию `2` (сколько ревьюеров назначать автоматически)
|
||||||
|
- `APPROVALS_REQUIRED_FOR_MERGE` - необязательно, по умолчанию `2` (порог approve для сообщения "можно мерджить")
|
||||||
- `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 (без `@`)
|
||||||
- `GITEA_COUNT_REPOS` - необязательно, CSV репозиториев `org/repo` для расчета нагрузки ревьюеров
|
- `GITEA_COUNT_REPOS` - необязательно, CSV репозиториев `org/repo` для расчета нагрузки (исторический счетчик назначений)
|
||||||
- `BOT_DB_PATH` - необязательно, по умолчанию `botreviewer.sqlite3`
|
- `BOT_DB_PATH` - необязательно, по умолчанию `botreviewer.sqlite3`
|
||||||
|
|
||||||
## Webhook-эндпоинты
|
## Webhook-эндпоинты
|
||||||
@ -42,7 +56,10 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
|
|||||||
- Webhook Gitea: `POST /gitea/webhook`
|
- Webhook Gitea: `POST /gitea/webhook`
|
||||||
|
|
||||||
Для Telegram укажите URL вашего сервиса с путем `/telegram/webhook` и `secret_token`.
|
Для Telegram укажите URL вашего сервиса с путем `/telegram/webhook` и `secret_token`.
|
||||||
Для Gitea включите события `pull_request` и задайте общий секрет.
|
Для Gitea задайте общий secret и включите события:
|
||||||
|
- `pull_request`
|
||||||
|
- `issue_comment`
|
||||||
|
- `pull_request_review`
|
||||||
|
|
||||||
## Локальная разработка через Tuna
|
## Локальная разработка через Tuna
|
||||||
|
|
||||||
@ -51,7 +68,7 @@ uvicorn main:app --host 0.0.0.0 --port 8080 --reload --env-file .env
|
|||||||
1. Запустите бота:
|
1. Запустите бота:
|
||||||
|
|
||||||
```bash
|
```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, например:
|
2. Поднимите туннель в Tuna(https://my.tuna.am/) на локальный порт `8080` и получите публичный URL, например:
|
||||||
@ -71,7 +88,7 @@ https://example.ru.tuna.am/health
|
|||||||
- Method: `POST`
|
- Method: `POST`
|
||||||
- Content Type: `application/json`
|
- Content Type: `application/json`
|
||||||
- Secret: тот же, что в `GITEA_WEBHOOK_SECRET`
|
- Secret: тот же, что в `GITEA_WEBHOOK_SECRET`
|
||||||
- Events: `Pull Request` (Custom Events)
|
- Events: `Pull Request`, `Issue Comment`, `Pull Request Review`
|
||||||
|
|
||||||
5. Настройте webhook в Telegram:
|
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")))
|
AUTO_ASSIGN_REVIEWERS_COUNT = max(1, int(os.getenv("AUTO_ASSIGN_REVIEWERS_COUNT", "2")))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
AUTO_ASSIGN_REVIEWERS_COUNT = 2
|
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"
|
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 = {
|
||||||
@ -335,6 +339,10 @@ class Storage:
|
|||||||
(key, value),
|
(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:
|
def get_reminder_time(self, telegram_chat_id: int) -> str | None:
|
||||||
return self.get_kv(f"reminder_time:{telegram_chat_id}")
|
return self.get_kv(f"reminder_time:{telegram_chat_id}")
|
||||||
|
|
||||||
@ -444,6 +452,21 @@ class GiteaClient:
|
|||||||
return []
|
return []
|
||||||
return data
|
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)
|
storage = Storage(DB_PATH)
|
||||||
telegram_client = TelegramClient(TELEGRAM_BOT_TOKEN)
|
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
|
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(
|
async def get_pr_participant_chat_ids(
|
||||||
repo_full_name: str,
|
repo_full_name: str,
|
||||||
pr_number: int,
|
pr_number: int,
|
||||||
@ -783,6 +822,7 @@ 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))
|
||||||
|
storage.delete_kv(f"merge_ready_notified:{repo_full_name}:{int(pr_number)}")
|
||||||
is_merged = action == "merged" or bool(pull_request.get("merged"))
|
is_merged = action == "merged" or bool(pull_request.get("merged"))
|
||||||
status_label = "смержен" if is_merged else "закрыт"
|
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}"
|
||||||
@ -876,7 +916,7 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
|
|||||||
state = (review.get("state") or "").lower()
|
state = (review.get("state") or "").lower()
|
||||||
reviewer_login = (review.get("user") or {}).get("login") or ""
|
reviewer_login = (review.get("user") or {}).get("login") or ""
|
||||||
if state == "approved":
|
if state == "approved":
|
||||||
status_text = "одобрен"
|
status_text = "одобрен(апрувнут)"
|
||||||
elif state in ("request_changes", "request changes"):
|
elif state in ("request_changes", "request changes"):
|
||||||
status_text = "запрошены правки"
|
status_text = "запрошены правки"
|
||||||
else:
|
else:
|
||||||
@ -890,6 +930,33 @@ async def handle_pr_reviewed(payload: dict[str, Any]) -> None:
|
|||||||
):
|
):
|
||||||
await telegram_client.send_message(chat_id, msg)
|
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:
|
async def reminder_loop() -> None:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user