Skip to content

REDKARASIK/pr-reviewer-assignment-service

Repository files navigation

pr-reviewer-assignment-service

PR Reviewer Assignment Service
Тестовое задание, осень 2025 (Avito Trainee Backend).

На этом этапе настроена инфраструктура проекта:

  • PostgreSQL в Docker
  • миграции через migrate/migrate в отдельном контейнере
  • backend-приложение в Docker
  • управление через Makefile

Дальше поверх этого будет развиваться бизнес-логика сервиса распределения ревьюверов. Также сделана статистика, описание линтера, сonfigs.


Что не успел сделать:

  1. Хотел бы реализовать более гибкие handler'ы статистики
  2. В дальнейшем хотелось бы иметь CACHE (in-memory-cache, redis и тд) допустим для команд
  3. Тестирование + Нагрузочное тестирование

Стек

  • Go — backend-сервис (сборка в Docker)
  • PostgreSQL 17 (alpine) - основная БД
  • golang-migrate (образ migrate/migrate) - применение SQL-миграций
  • Docker + Docker Compose
  • Makefile для удобных команд

ERD Диаграмма базы данных

ERD


Архитектура HTTP-слоя

На этом этапе в проекте появились три ключевых файла, отвечающих за базовый HTTP-слой backend-приложения:

internal/app/app.go - инициализация приложения (App)

Здесь создаётся структура App, которая отвечает за:

  • подключение к базе данных (pgxpool),
  • создание корневого HTTP-роутера,
  • запуск приложения (Run),
  • корректное завершение работы (Close).

Файл не содержит маршрутов или хэндлеров - он только собирает базовые зависимости.


internal/http/router/ - реализация кастомного Router

Файл internal/http/router/router.go содержит собственную реализацию Router поверх http.ServeMux.

Функциональность:

  • группировка маршрутов (Group("/prefix"))
  • регистрация методов (GET, POST, PUT, DELETE)
  • привязка обработчиков через метод + путь
    (используется схема "METHOD /path" внутри ServeMux)

Этот Router — низкоуровневый слой, который не знает ничего о логике приложения.


internal/http/router.go - регистрация маршрутов (Routes)

Файл определяет функцию:

func RegisterRoutes(h RoutesHandlers) http.Handler {}

Она отвечает за:

  • создание групп маршрутов (/users, /teams, /prs, /api/v1 и т.д.),
  • привязку HTTP-маршрутов к хэндлерам,
  • возврат готового http.Handler для запуска внутри main.go.

На текущем этапе здесь задаётся базовый каркас роутинга, который будет расширяться по мере появления новых хэндлеров.


PullRequests Logic

Этот раздел собирает ключевую доменную логику, которая лежит в основе распределения ревьюверов и поведения PR-сервиса.

Основные правила

  1. Ревьюверы всегда выбираются только из активных членов команды автора.

    • Пользователь считается кандидатом, только если:
      • состоит в той же команде, что и автор PR,
      • его is_active = true,
      • он не является автором PR,
      • он не является заменяемым ревьювером (в случае reassign).
  2. Ревьюверы сортируются по минимальному числу назначенных ревью (PRReviews).

    Это распределяет нагрузку равномерно.

  3. Максимум 2 ревьювера на PR.
    Значение параметра задаётся константой PRReviewers = 2.

  4. merge — идемпотентная операция.

    • повторный вызов /pullRequest/merge не меняет merged_at,
    • возвращает итоговое состояние PR.
  5. reassign всегда ищет кандидатов только в команде автора PR.

    Это исключает случаи, когда заменяемый ревьювер состоит в другой команде, и предотвращает ошибку NO_CANDIDATE, если фактические кандидаты есть у автора.

  6. Кандидаты при reassign выбираются только среди активных участников.

    Если все участники команды автора is_active = false (кроме автора) — корректно возвращается NO_CANDIDATE.

Обновлённое доменное правило

Если заменяемый ревьювер состоит не в команде автора,
reassign всё равно ищет замену в команде автора PR,
а не в команде заменяемого.

Это исправляет ситуацию, когда в команде автора есть 4 активных разработчика,
а кандидат выбирался по ошибке из другой команды.


API

API доступно по адресу: http://localhost:8080

Swagger UI (генерируется через swaggo) будет доступен здесь:

👉 http://localhost:8080/swagger/index.html

Users tag

В сервисе реализованы две ручки Users, соответствующие спецификации OpenAPI.


POST /users/setIsActive

Устанавливает флаг активности пользователя.

Описание: Обновляет поле 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_FIELD
  • 404 USER_NOT_FOUND
  • 500 INTERNAL_ERROR

GET /users/getReview

Возвращает 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_id
  • 500 INTERNAL_ERROR

Team tag

В задании по OpenAPI для Teams требовалась ручка только на создание команды (/team/add) и возвращение ошибки TEAM_EXISTS, если команда с таким именем уже есть.

На практике при разработке сразу возник вопрос:

"А как изменять состав команды, когда нужно добавить/удалить участников или поменять активность?"

Чтобы не плодить отдельные ручки и упростить клиентский код, было принято решение расширить поведение POST /team/add:

  • если команда не существует — она создаётся (как в исходном требовании),
  • если команда уже существует — этот же метод:
    • обновляет состав участников в таблице team_members (добавляет новых, удаляет отсутствующих),
    • обновляет флаги is_active у переданных пользователей.

Таким образом, /team/add работает как upsert команды: "создать или синхронизировать состав по переданному списку участников".


POST /team/add

Создать новую команду или обновить существующую.

Во время разработки встал вопрос, а как добавлять участников, раз 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
    }
  ]
}

GET /team/get

Получить команду с участниками

Успешный ответ (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.


PullRequests tag

Сервис реализует полный цикл работы с PR внутри команды:
автоматическое назначение ревьюверов, получение списка PR для ревью,
идемпотентный merge и переназначение ревьюверов.


POST /pullRequest/create

Создаёт 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 — сбой сервиса


POST /pullRequest/merge

Идемпотентно переводит 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 — сбой сервиса


POST /pullRequest/reassign

Переназначает конкретного ревьювера на другого.

Основные правила переназначения:
  • Кандидаты выбираются только из команды автора 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(...).


Дополнительный функционал

Эндпоинт статистики назначений по пользователям

URL

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 и настроена так, чтобы:

  1. Ловить реальные ошибки ещё до запуска (потерянные проверки ошибок, подозрительные конструкции, лишние append и т.п.).
  2. Поддерживать единый стиль кода (комментарии к экспортируемым сущностям, нейминг, аккуратные пакеты).
  3. Упростить ревью — меньше обсуждать формат и очевидные ошибки, больше — логику.

Включённые линтеры

  • 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/.


Пример config.toml

Создайте файл в корне проекта (важно: это должен быть файл, а не директория):

[http]
host = "0.0.0.0"
port = 8080

[postgres]
host     = "localhost"
port     = 5432
user     = "postgres"
password = "postgres"
database = "pr_reviewer"
sslmode  = "disable"

Быстрый старт

1. config.toml

В репозитории (корне проекта) лежит config.example.toml.

Чтобы приложение работало необходимо прежде всего выполнить команду в корне проекта:

cp config.example.toml config.toml

Которая сделает копию примера config.example.toml, на котором запуститься docker.

2. Зависимости

Нужно установить:

3. Поднять окружение

Основной запуск:

make up

Альтернативный запуск:

Если тебе не нужно разделять шаги (db → migrate → app), ты можешь поднять весь стек одной командой:

docker compose up --build

About

PR Reviewer Assignment Service (Test Task, Fall 2025)

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages