PR Reviewer Assignment Service
Тестовое задание, осень 2025 (Avito Trainee Backend).
На этом этапе настроена инфраструктура проекта:
- PostgreSQL в Docker
- миграции через
migrate/migrateв отдельном контейнере - backend-приложение в Docker
- управление через
Makefile
Дальше поверх этого будет развиваться бизнес-логика сервиса распределения ревьюверов. Также сделана статистика, описание линтера, сonfigs.
- Хотел бы реализовать более гибкие handler'ы статистики
- В дальнейшем хотелось бы иметь CACHE (in-memory-cache, redis и тд) допустим для команд
- Тестирование + Нагрузочное тестирование
- Go — backend-сервис (сборка в Docker)
- PostgreSQL 17 (alpine) - основная БД
- golang-migrate (образ
migrate/migrate) - применение SQL-миграций - Docker + Docker Compose
- Makefile для удобных команд
На этом этапе в проекте появились три ключевых файла, отвечающих за базовый HTTP-слой backend-приложения:
Здесь создаётся структура App, которая отвечает за:
- подключение к базе данных (pgxpool),
- создание корневого HTTP-роутера,
- запуск приложения (
Run), - корректное завершение работы (
Close).
Файл не содержит маршрутов или хэндлеров - он только собирает базовые зависимости.
Файл internal/http/router/router.go содержит собственную реализацию Router поверх http.ServeMux.
Функциональность:
- группировка маршрутов (
Group("/prefix")) - регистрация методов (
GET,POST,PUT,DELETE) - привязка обработчиков через метод + путь
(используется схема"METHOD /path"внутри ServeMux)
Этот Router — низкоуровневый слой, который не знает ничего о логике приложения.
Файл определяет функцию:
func RegisterRoutes(h RoutesHandlers) http.Handler {}Она отвечает за:
- создание групп маршрутов (/users, /teams, /prs, /api/v1 и т.д.),
- привязку HTTP-маршрутов к хэндлерам,
- возврат готового http.Handler для запуска внутри main.go.
На текущем этапе здесь задаётся базовый каркас роутинга, который будет расширяться по мере появления новых хэндлеров.
Этот раздел собирает ключевую доменную логику, которая лежит в основе распределения ревьюверов и поведения PR-сервиса.
-
Ревьюверы всегда выбираются только из активных членов команды автора.
- Пользователь считается кандидатом, только если:
- состоит в той же команде, что и автор PR,
- его
is_active = true, - он не является автором PR,
- он не является заменяемым ревьювером (в случае reassign).
- Пользователь считается кандидатом, только если:
-
Ревьюверы сортируются по минимальному числу назначенных ревью (
PRReviews).Это распределяет нагрузку равномерно.
-
Максимум 2 ревьювера на PR.
Значение параметра задаётся константойPRReviewers = 2. -
merge — идемпотентная операция.
- повторный вызов
/pullRequest/mergeне меняетmerged_at, - возвращает итоговое состояние PR.
- повторный вызов
-
reassign всегда ищет кандидатов только в команде автора PR.
Это исключает случаи, когда заменяемый ревьювер состоит в другой команде, и предотвращает ошибку
NO_CANDIDATE, если фактические кандидаты есть у автора. -
Кандидаты при reassign выбираются только среди активных участников.
Если все участники команды автора
is_active = false(кроме автора) — корректно возвращаетсяNO_CANDIDATE.
Если заменяемый ревьювер состоит не в команде автора,
reassign всё равно ищет замену в команде автора PR,
а не в команде заменяемого.
Это исправляет ситуацию, когда в команде автора есть 4 активных разработчика,
а кандидат выбирался по ошибке из другой команды.
API доступно по адресу: http://localhost:8080
Swagger UI (генерируется через swaggo) будет доступен здесь:
👉 http://localhost:8080/swagger/index.html
В сервисе реализованы две ручки Users, соответствующие спецификации OpenAPI.
Устанавливает флаг активности пользователя.
Описание:
Обновляет поле is_active у пользователя и возвращает обновлённый объект.
Тело запроса:
{
"user_id": "u2",
"is_active": false
}Успешный ответ (200):
{
"user": {
"user_id": "u2",
"username": "Bob",
"team_name": "backend",
"is_active": false
}
}Ошибки:
400 INVALID_JSON / MISSING_FIELD404 USER_NOT_FOUND500 INTERNAL_ERROR
Возвращает PR'ы, в которых пользователь является ревьювером.
Query-параметры:
user_id— обязательный
Пример:
GET /users/getReview?user_id=u2
Успешный ответ (200):
{
"user_id": "u2",
"pull_requests": [
{
"pull_request_id": "pr-1001",
"pull_request_name": "Add search",
"author_id": "u1",
"status": "OPEN"
}
]
}Ошибки:
400 MISSING_FIELD— если отсутствует user_id500 INTERNAL_ERROR
В задании по OpenAPI для Teams требовалась ручка только на создание команды (/team/add) и возвращение ошибки TEAM_EXISTS, если команда с таким именем уже есть.
На практике при разработке сразу возник вопрос:
"А как изменять состав команды, когда нужно добавить/удалить участников или поменять активность?"
Чтобы не плодить отдельные ручки и упростить клиентский код, было принято решение расширить поведение POST /team/add:
- если команда не существует — она создаётся (как в исходном требовании),
- если команда уже существует — этот же метод:
- обновляет состав участников в таблице
team_members(добавляет новых, удаляет отсутствующих), - обновляет флаги
is_activeу переданных пользователей.
- обновляет состав участников в таблице
Таким образом, /team/add работает как upsert команды: "создать или синхронизировать состав по переданному списку участников".
Создать новую команду или обновить существующую.
Во время разработки встал вопрос, а как добавлять участников, раз team/add работает,
как upsert, было принято решение через него добавлять user'ов
Описание:
- При первом вызове с новым
team_name— создаёт команду и связывает её с участниками. - При повторных вызовах с тем же
team_name:- для всех переданных
membersобновляетis_activeу пользователей, - в таблице
team_members:- добавляет новых
user_id, которых раньше не было в этой команде, - удаляет тех, кого больше нет в списке
members.
- добавляет новых
- для всех переданных
Тело запроса:
{
"team_name": "backend",
"members": [
{
"user_id": "u1",
"username": "Alice",
"is_active": true
},
{
"user_id": "u2",
"username": "Bob",
"is_active": false
}
]
}Успешный ответ (201)
{
"team_name": "backend",
"members": [
{
"user_id": "u1",
"username": "Alice",
"is_active": true
},
{
"user_id": "u2",
"username": "Bob",
"is_active": false
}
]
}Получить команду с участниками
Успешный ответ (200)
{
"team_name": "backend",
"members": [
{
"user_id": "u1",
"username": "Alice",
"is_active": true
},
{
"user_id": "u2",
"username": "Bob",
"is_active": true
}
]
}Ошибки
404 - Команда не найдена
500 - INTERNAL_SERVER ERROR
На уровне реализации состав команды обновляется транзакционно:
-
читается текущий список участников,
-
строится diff по user_id,
-
выполняются нужные INSERT/DELETE в users.team_members,
-
для переданных пользователей обновляется is_active.
Ошибки:
400 INVALID_JSON / MISSING_FIELD — невалидный запрос;
404 NOT_FOUND — если какой-то из переданных пользователей не найден;
409 TEAMS_CONFLICT — если для существующей команды передан пользователь, который уже привязан к другой команде;
500 INTERNAL_ERROR.
Сервис реализует полный цикл работы с PR внутри команды:
автоматическое назначение ревьюверов, получение списка PR для ревью,
идемпотентный merge и переназначение ревьюверов.
Создаёт pull request и автоматически назначает до двух ревьюверов.
Логика:
- определяется команда автора PR;
- выбираются только активные участники этой команды (is_active = true);
- автор PR не может быть ревьювером;
- кандидаты сортируются по минимальному количеству назначенных ревью (
PRReviews); - выбираются до
PRReviewers = 2участников.
Request:
{
"pull_request_id": "pr-1001",
"pull_request_name": "Add search feature",
"author_id": "u1"
}Response (201):
{
"pr": {
"pull_request_id": "pr-1001",
"pull_request_name": "Add search feature",
"author_id": "u1",
"status": "OPEN",
"assigned_reviewers": ["u2", "u3"]
}
}
Ошибки:
-
INVALID_JSON — неверный формат запроса
-
NOT_FOUND — не найден автор или его команда
-
PR_EXISTS — PR с таким id уже существует
-
INTERNAL_ERROR — сбой сервиса
Идемпотентно переводит PR в статус MERGED.
-
повторный вызов не меняет данные;
-
merged_at выставляется только один раз;
-
если PR уже MERGED — возвращается текущее состояние;
-
если PR не существует — ошибка NOT_FOUND.
Request:
{
"pull_request_id": "pr-1001"
}Response (200):
{
"pr": {
"pull_request_id": "pr-1001",
"pull_request_name": "Add search feature",
"author_id": "u1",
"status": "MERGED",
"assigned_reviewers": ["u2", "u3"],
"merged_at": "2025-11-16T20:01:23Z"
}
}Ошибки:
-
NOT_FOUND — PR не существует
-
INTERNAL_ERROR — сбой сервиса
Переназначает конкретного ревьювера на другого.
-
Кандидаты выбираются только из команды автора PR, а не заменяемого ревьювера.
-
Кандидаты должны быть активными (is_active = true).
-
Новые ревьюверы сортируются по минимальному количеству ревью (PRReviews).
-
Автор PR не может быть ревьювером.
-
Нельзя менять ревьюверов у MERGED PR.
-
Если нет подходящих кандидатов — возвращается NO_CANDIDATE.
Request:
{
"pull_request_id": "pr-1001",
"old_reviewer_id": "u2"
}Response (200):
{
"pr": {
"pull_request_id": "pr-1001",
"pull_request_name": "Add search feature",
"author_id": "u1",
"status": "OPEN",
"assigned_reviewers": ["u3", "u5"]
},
"replaced_by": "u5"
}Ошибки:
-
NOT_FOUND — PR или пользователь не существует
-
NOT_ASSIGNED — старый ревьювер не назначен на этот PR
-
PR_MERGED — PR уже смержен
-
NO_CANDIDATE — нет активных кандидатов в команде автора
-
INTERNAL_ERROR — сбой сервиса
Возвращают ошибки в едином формате ErrorResponse, используя общий helper response.Error(...).
GET /api/stats/users
Эндпоинт возвращает статистику по пользователям:
сколько раз каждому пользователю были назначены pull request’ы на рассмотрение.
Результат всегда отсортирован по количеству назначений по убыванию (сначала самые часто назначаемые пользователи).
Все параметры опциональные, передаются как query-параметры.
-
limit— максимальное количество записей в ответе.- тип:
int - по умолчанию:
50 - минимальное значение:
1 - максимальное значение:
100
Если передано значение больше 100, применяетсяlimit = 100.
Если передано некорректное значение (не число или<= 0), используется значение по умолчанию.
- тип:
-
offset— смещение выборки (для пагинации).- тип:
int - по умолчанию:
0 - минимальное значение:
0
Если передано некорректное значение (не число или< 0), используется значение по умолчанию.
- тип:
Таким образом, можно постранично проходить по списку пользователей:
страница = offset = page * limit.
Успешный ответ 200 OK:
{
"items": [
{
"user_id": "string",
"username": "string",
"assignments_count": 0
}
],
"total": 0,
"limit": 50,
"offset": 0
}В проекте используется golangci-lint для статического анализа кода и единых правил стиля.
Конфигурация лежит в .golangci.yml и настроена так, чтобы:
- Ловить реальные ошибки ещё до запуска (потерянные проверки ошибок, подозрительные конструкции, лишние append и т.п.).
- Поддерживать единый стиль кода (комментарии к экспортируемым сущностям, нейминг, аккуратные пакеты).
- Упростить ревью — меньше обсуждать формат и очевидные ошибки, больше — логику.
-
govet
Базовый аналитик от Go: находит типичные баги (неправильные форматыPrintf, подозрительные участки кода, возможные проблемы с типами). -
errcheck
Требует явной обработки ошибок. Помогает не пропуститьerrв вызовах вродеdefer r.Body.Close()и не игнорировать важные ошибки. -
staticcheck
Продвинутый статический анализатор. Ищет:- мёртвый код,
- бессмысленные/лишние операции,
- неправильное использование стандартной библиотеки,
- потенциальные баги и неэффективные конструкции.
-
revive
Линтер стиля. В текущей конфигурации:- требует комментарии к экспортируемым типам, константам и ошибкам;
- следит за названиями (например,
userIDвместоuserId); - проверяет наличие комментариев к пакетам.
Локально (из корня проекта):
golangci-lint runПриложение использует внешний файл config.toml для настройки HTTP-сервера и подключения к базе данных.
Конфигурация загружается при запуске и передаётся в приложение до инициализации сервисов.
- читает параметры из файла
config.toml; - собирает строку подключения к PostgreSQL;
- используется в
main.goдля запуска приложения.
Функции чтения и сборки конфига находятся в internal/config/.
Создайте файл в корне проекта (важно: это должен быть файл, а не директория):
[http]
host = "0.0.0.0"
port = 8080
[postgres]
host = "localhost"
port = 5432
user = "postgres"
password = "postgres"
database = "pr_reviewer"
sslmode = "disable"В репозитории (корне проекта) лежит config.example.toml.
Чтобы приложение работало необходимо прежде всего выполнить команду в корне проекта:
cp config.example.toml config.tomlКоторая сделает копию примера config.example.toml, на котором запуститься docker.
Нужно установить:
- Docker
- Docker Compose
make(обычно уже есть в Linux/macOS)
make upЕсли тебе не нужно разделять шаги (db → migrate → app), ты можешь поднять весь стек одной командой:
docker compose up --build