Сервис назначения ревьюеров для Pull Request’ов по спецификации openapi.yaml.
Сервис написан на Go, использует PostgreSQL в качестве хранилища и запускается через docker-compose up.
Слои:
-
HTTP (internal/api/http)
- Обработчики (handlers) принимают/отдают JSON, парсят вход, делают базовую валидацию (обязательные поля).
- Перекладывают всё в сервисный слой (никакой бизнес-логики внутри handler’ов).
- Маппинг доменных ошибок (
AppError) на HTTP-коды строго по спецификации.
-
Сервисный слой / usecase (internal/api/service)
- Содержит все бизнес-правила:
- выбор активных ревьюеров при создании PR;
- проверка статуса PR перед merge/reassign;
- логика переназначения ревьюера на активного участника той же команды с учётом ограничений;
- логика массовой деактивации и безопасного переназначения;
- подсчёт статистики.
- Репозитории спрятаны за методами сервисов — удобно тестировать бизнес-логику отдельно.
- Содержит все бизнес-правила:
-
Сущности (internal/entity)
- Модели предметной области:
User,Team,TeamMember,PullRequest,PullRequestShort,Stats,DeactivateResult,AppErrorи т.п. - Enum-подобные коды ошибок (
ErrorCode), чтобы не разъезжались строковые значения между слоями.
- Модели предметной области:
-
Репозитории (internal/repository)
- Инкапсулируют доступ к БД: SQL-запросы, сканы структур.
- Для операций, которые должны быть атомарными, есть методы вида
...Tx(работают внутри переданной транзакции, не создают её сами).
-
Инфраструктура (internal/db, internal/app)
internal/db/postgres.go— создание*sql.DB, настройки пула соединений.internal/app/app.go— сборка зависимостей (репозитории → сервисы → handler), настройка HTTP-сервера, middleware и запуск.cmd/server/main.go— точка входа, просто вызываетapp.Run().
Сервис позволяет:
- управлять командами и пользователями;
- создавать Pull Request’ы и автоматически назначать до двух ревьюеров из команды автора (только активные, без автора);
- выполнять переназначение одного ревьювера на случайного активного участника его команды;
- помечать PR как MERGED (операция идемпотентна);
- получать список PR’ов, где пользователь назначен ревьювером;
- (доп. задание) массово деактивировать пользователей команды и безопасно переназначать ревьюеров в открытых PR;
- (доп. задание) получать простую статистику по назначениям;
- работать через HTTP API, описанное в
openapi.yaml.
В задании явно указано, что при отсутствии ответа в условиях кандидат может самостоятельно принимать решения и зафиксировать их в README. Ниже перечислены ключевые допущения, которые были сделаны в этом проекте.
-
Что строго соблюдаем:
- Все описанные в
openapi.yamlэндпоинты реализованы по спецификации:- те же пути, методы, структуры запросов и ответов;
- формат ошибок:
{ "error": { "code": "...", "message": "..." } } - коды ошибок (
TEAM_EXISTS,PR_EXISTS,PR_MERGED,NOT_ASSIGNED,NO_CANDIDATE,NOT_FOUND).
- Все описанные в
-
Что добавлено сверх спецификации:
- Эндпоинт статистики
/stats(доп. задание). - Эндпоинт массовой деактивации
/team/deactivateAndReassign(доп. задание).
- Эндпоинт статистики
- Вся доменная ошибка представлена через
AppError{Code, Message}(слойentity). - Маппинг на HTTP-коды:
TEAM_EXISTS→ 400 Bad Request (/team/addуже существует).PR_EXISTS,PR_MERGED,NOT_ASSIGNED,NO_CANDIDATE→ 409 Conflict (нарушение доменных правил).NOT_FOUND→ 404 Not Found (PR, команда или пользователь не найдены).BAD_REQUEST→ 400 Bad Request (ошибка валидации тела запроса).- Любая нераспознанная ошибка → 500 Internal Server Error.
- Ревьюверы выбираются из команды автора.
- Выбираются только пользователи с
is_active = true. - Автор PR никогда не может быть ревьювером самого себя.
- Максимум 2 ревьювера:
- если доступных ≥ 2 — назначаем двух;
- если доступен только один — назначаем одного;
- если никого нет — PR создаётся без ревьюверов (пустой список).
- Если PR в статусе
OPEN:- статус переводится в
MERGED, - выставляется
mergedAt, - возвращается обновлённый PR.
- статус переводится в
- Если PR уже в статусе
MERGED:- состояние не меняется,
- возвращается существующее состояние PR,
- HTTP-код — 200 OK (это и есть идемпотентность).
- Если PR с таким
pull_request_idне существует → 404 NOT_FOUND.
Почему так:
Потому что логично отличать «повторный merge существующего PR» от «merge несуществующего PR».
- Переназначать можно только существующий PR.
- Если PR уже
MERGED→ ошибкаPR_MERGED(HTTP 409). - Переназначать можно только пользователя, который реально назначен ревьювером для этого PR:
- иначе —
NOT_ASSIGNED(409).
- иначе —
- Новый ревьювер выбирается:
- из активных пользователей команды заменяемого ревьювера;
- исключая:
- самого автора PR,
- уже назначенных ревьюверов.
- Если доступных кандидатов нет →
NO_CANDIDATE(409).
- Все указанные пользователи в команде помечаются как
is_active = false. - Для всех открытых PR этой команды, где деактивированные пользователи были ревьюверами:
- пытаемся найти замену по тем же правилам, что и в обычном
reassign; - если замены нет — этот ревьювер просто удаляется из PR.
- пытаемся найти замену по тем же правилам, что и в обычном
- PR в статусе
MERGEDне изменяются.
Считаем, что:
- изменения в
MERGEDPR недопустимы (они уже зафиксированы), - отсутствие возможного ревьювера лучше обработать удалением, чем падением всей операции.
-
Для операций, где важна атомарность:
/team/add(создание команды + upsert пользователей),/pullRequest/create(создание PR + назначение ревьюверов),/pullRequest/merge,reassign,deactivateAndReassign,
используется единая транзакция.
-
Репозитории не создают транзакций сами — они принимают
*sql.Tx(...Tx-методы) и работают в рамках уже открытой транзакции.
Решение о границах транзакции — часть бизнес-логики, а не репозитория. Это упрощает тестирование и соблюдает принцип единой ответственности.
- При
docker-compose up:- поднимается Postgres,
- применяются миграции,
- поднимается сервис.
- Тестовые данные НЕ создаются автоматически.
- Генерация данных (
20команд,~200пользователей,~1000PR) выполняется отдельным сидером (go run ./cmd/seed), описанным в README.
Почему я решил так:
- Можно использовать сервис с чистой базой в других средах,
- Можно воспроизводить конкретные кейсы.
При этом наличие скрипта и инструкции позволяет легко воспроизвести окружение для нагрузочного тестирования.
- Все чувствительные данные (DSN для БД и т.п.) берутся из переменных окружения.
- В репозитории хранится только
.env.exampleс тестовыми значениями. - Реальный
.envсоздаётся локально (cp .env.example .env) и не коммитится. - В
docker-compose.ymlесть дефолтные значения для переменных через${VAR:-default}, но они считаются «локальными» и могут быть переопределены в реальной среде.
Таким образом
- не ломается запуск
docker-compose up«из коробки»; - но при этом не хранятся реальные секреты в репозитории.
- Для каждого HTTP-запроса в middleware генерируется
request_id(UUID). - В логах все сообщения завязаны на
request_id, чтобы в будущем их можно было собрать централизованно и трассировать сценарии - Логируем:
- ошибки сервисов и неожиданные ситуации (
500), - доменные ошибки через
writeDomainError(без чувствительных данных), - факт старта приложения и основные параметры (порт, лимит памяти, GOMAXPROCS).
- ошибки сервисов и неожиданные ситуации (
Это просто best practice, я посчитал нужным и полезным включить
-
Проект ориентирован на объём:
- до 20 команд,
- до 200 пользователей,
- до ~1000 PR.
-
С учётом таких объёмов:
- не используется кэш, достаточно одной PostgreSQL;
- подобраны индексы по ключевым полям (
pull_request_id,user_id,team_name, статусам и связям).
-
SLI из условия:
- RPS ≈ 5,
- время ответа ≤ 300 мс,
- успешность 99.9%
проверялись через
heyна сгенерированных данных. Реальные результаты (p95/p99 и успешность) приведены в разделе нагрузочного тестирования.
Почему так:
на таких объёмах разумнее сделать код простым и честно придерживаться SLI, чем усложнять архитектуру кэшем / шардированием, которые в данном масштабе не нужны.
Упрощённое дерево (главные директории):
.
├── cmd
│ ├── server
│ │ └── main.go # запуск HTTP-сервера
│ └── seed
│ └── main.go # сидер для генерации тестовых данных через HTTP
├── internal
│ ├── app
│ │ └── app.go # сборка и запуск приложения
│ ├── api
│ │ ├── http
│ │ │ ├── handler.go # роутер + регистрация эндпоинтов
│ │ │ ├── pr.go # обработчики PR (/pullRequest/*)
│ │ │ ├── team.go # обработчики команд (/team/*)
│ │ │ ├── user.go # обработчики пользователей (/users/*)
│ │ │ ├── stats.go # обработчик статистики (/stats)
│ │ │ ├── responses.go #WriteJSON, writeError, writeDomainError
│ │ │ └── middleware.go # логирование + request-id
│ │ └── service
│ │ ├── team_service.go # логика по командам
│ │ ├── user_service.go # логика по пользователям
│ │ ├── pr_service.go # логика по Pull Request’ам
│ │ └── stats_service.go # логика для ст_
Для проверки стиля и базовых ошибок используется golangci-lint.
Конфиг лежит в файле .golangci.yml в корне проекта.
govet— поиск типичных ошибок (подозрительные конструкции, неправильное использованиеPrintf, и т.п.);staticcheck— набор семантических проверок;gosimple— замечания по чрезмерно усложнённым конструкциям;errcheck— проверка, что ошибки не игнорируются;ineffassign— поиск неиспользуемых присваиваний;gofmt/goimports— автоформат и порядок импортов.
Прописываем docker-compose up, при условии что запущен docker desktop ( 0_0 я в моменте не увидел когда впервые собирал, что он вырублен)
Не забываем тогда создать локально .env (можно просто сделать cp .env.example .env)
Потом чтобы заполнить БД тестовыми данными используем сидер
go run ./cmd/seed
После этого можем кидать запросы, смотреть ответы - я использовал hey и об результатах расписал в следующем разделе
ВАЖНО для следующей секции, тесты с hey написаны так чтобы работать с cmd в windows, чтобы они работали в линуксовском терминале нужно убрать оборачивание кавычек
- тест на read -
hey -z 60s -q 5 -c 5 "http://localhost:8080/users/getReview?user_id=team-01-u-01"
Summary: Total: 28.4082 secs Slowest: 0.0522 secs Fastest: 0.0027 secs Average: 0.0073 secs Requests/sec: 24.9928
Total data: 790230 bytes Size/request: 1113 bytes
Response time histogram: 0.003 [1] |
0.008 [498] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.013 [174] |■■■■■■■■■■■■■■
0.018 [26] |■■
0.022 [3] | 0.027 [3] | 0.032 [2] | 0.037 [1] | 0.042 [0] | 0.047 [1] | 0.052 [1] |
Latency distribution: 10% in 0.0044 secs
25% in 0.0054 secs
50% in 0.0064 secs
75% in 0.0083 secs
90% in 0.0109 secs
95% in 0.0128 secs
99% in 0.0234 secs
Details (average, fastest, slowest): DNS+dialup: 0.0001 secs, 0.0027 secs, 0.0522 secs
DNS-lookup: 0.0001 secs, 0.0000 secs, 0.0193 secs
req write: 0.0001 secs, 0.0000 secs, 0.0088 secs
resp wait: 0.0067 secs, 0.0026 secs, 0.0314 secs
resp read: 0.0002 secs, 0.0000 secs, 0.0121 secs
Status code distribution: [200] 710 responses
2)Тест на merge hey -z 30s -q 5 -c 5 -m POST -H "Content-Type: application/json" -d "{\"pull_request_id\":\"pr-0002\"}" "http://localhost:8080/pullRequest/merge"
Summary: Total: 17.8319 secs Slowest: 0.0687 secs Fastest: 0.0042 secs Average: 0.0099 secs Requests/sec: 24.9553
Total data: 111250 bytes Size/request: 250 bytes
Response time histogram: 0.004 [1] |
0.011 [318] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.017 [101] |■■■■■■■■■■■■■
0.024 [16] |■■
0.030 [1] |
0.036 [6] |■
0.043 [0] |
0.049 [0] |
0.056 [0] |
0.062 [0] |
0.069 [2] |
Latency distribution:
10% in 0.0058 secs
25% in 0.0067 secs
50% in 0.0085 secs
75% in 0.0117 secs
90% in 0.0151 secs
95% in 0.0180 secs
99% in 0.0348 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0003 secs, 0.0042 secs, 0.0687 secs
DNS-lookup: 0.0002 secs, 0.0000 secs, 0.0214 secs
req write: 0.0001 secs, 0.0000 secs, 0.0164 secs
resp wait: 0.0092 secs, 0.0041 secs, 0.0685 secs
resp read: 0.0001 secs, 0.0000 secs, 0.0078 secs
Status code distribution: [200] 445 responses
- Тест на Доп.задание DeactivateReassign
>hey -z 30s -q 4 -c 4 -m POST -H "Content-Type: application/json" -d "{\"team_name\":\"team-01\",\"user_ids\":[\"team-01-u-05\",\"team-01-u-06\"]}" "http://localhost:8080/team/deactivateAndReassign"
Summary:
Total: 30.0143 secs
Slowest: 0.0771 secs
Fastest: 0.0035 secs
Average: 0.0075 secs
Requests/sec: 15.9924
Total data: 49441 bytes
Size/request: 103 bytes
Response time histogram: 0.003 [1] |
0.011 [433] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
0.018 [39] |■■■■
0.026 [2] |
0.033 [1] |
0.040 [0] |
0.048 [0] |
0.055 [0] |
0.062 [0] |
0.070 [0] |
0.077 [4] |
Latency distribution:
10% in 0.0044 secs
25% in 0.0051 secs
50% in 0.0060 secs
75% in 0.0084 secs
90% in 0.0108 secs
95% in 0.0125 secs
99% in 0.0718 secs
Details (average, fastest, slowest):
DNS+dialup: 0.0002 secs, 0.0035 secs, 0.0771 secs
DNS-lookup: 0.0002 secs, 0.0000 secs, 0.0201 secs
req write: 0.0001 secs, 0.0000 secs, 0.0074 secs
resp wait: 0.0070 secs, 0.0033 secs, 0.0557 secs
resp read: 0.0001 secs, 0.0000 secs, 0.0085 secs
Status code distribution:
[200] 480 responses
В целом видно что по требованиям к времени и rps укладываемся с большим запасом