Corp/tools/quality/quality.sh
2026-09-07 18:37:34 +03:00

290 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
readonly BACKEND_DIR="${REPO_ROOT}/backend"
readonly FRONTEND_DIR="${REPO_ROOT}/frontend"
readonly MODE="${1:-quick}"
readonly SCOPE="${2:-}"
if [[ ${MODE} != "quick" && ${MODE} != "test" && ${MODE} != "release" ]]; then
printf 'Usage: %s {quick|test|release} [--staged]\n' "$0" >&2
exit 2
fi
if [[ -n ${SCOPE} && ${SCOPE} != "--staged" ]]; then
printf 'Unknown option: %s\n' "${SCOPE}" >&2
exit 2
fi
log() {
printf '\n==> %s\n' "$*"
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
printf 'Required command is unavailable: %s\n' "$1" >&2
exit 1
fi
}
python_command() {
if [[ -x ${BACKEND_DIR}/.venv/bin/python ]]; then
printf '%s\n' "${BACKEND_DIR}/.venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
command -v python3
else
printf 'Python is unavailable. Create backend/.venv first.\n' >&2
exit 1
fi
}
npm_command() {
if command -v npm >/dev/null 2>&1; then
printf '%s\n' "$(command -v npm)"
elif command -v corepack >/dev/null 2>&1; then
printf '%s\n' "$(command -v corepack) npm"
else
printf 'npm is unavailable. Install the project Node.js version first.\n' >&2
exit 1
fi
}
resolve_comparison_base() {
local candidate="${QUALITY_BASE_SHA:-}"
if [[ -n ${candidate} && ! ${candidate} =~ ^0+$ ]] \
&& git -C "${REPO_ROOT}" cat-file -e "${candidate}^{commit}" 2>/dev/null; then
printf '%s\n' "${candidate}"
return
fi
if git -C "${REPO_ROOT}" show-ref --verify --quiet refs/remotes/origin/master; then
git -C "${REPO_ROOT}" merge-base HEAD origin/master
return
fi
if git -C "${REPO_ROOT}" rev-parse --verify HEAD^ >/dev/null 2>&1; then
git -C "${REPO_ROOT}" rev-parse HEAD^
return
fi
git -C "${REPO_ROOT}" hash-object -t tree /dev/null
}
collect_changed_files() {
local base
if [[ ${SCOPE} == "--staged" ]]; then
git -C "${REPO_ROOT}" diff --cached --name-only --diff-filter=ACMR -z
else
base="$(resolve_comparison_base)"
git -C "${REPO_ROOT}" diff "${base}" HEAD --name-only --diff-filter=ACMR -z
fi
}
check_partially_staged_files() {
local -a staged_files=()
local file
while IFS= read -r -d '' file; do
staged_files+=("${file}")
done < <(git -C "${REPO_ROOT}" diff --cached --name-only --diff-filter=ACMR -z)
for file in "${staged_files[@]}"; do
if ! git -C "${REPO_ROOT}" diff --quiet -- "${file}"; then
printf 'Partially staged file cannot be checked reliably: %s\n' "${file}" >&2
printf 'Stage all its changes or commit them separately.\n' >&2
exit 1
fi
done
}
run_changed_quality() {
local python
local -a npm=()
local -a python_files=()
local -a eslint_files=()
local -a prettier_files=()
local -a shell_files=()
local file relative
if [[ ${SCOPE} == "--staged" ]]; then
check_partially_staged_files
git -C "${REPO_ROOT}" diff --cached --check
else
git -C "${REPO_ROOT}" diff --check
fi
while IFS= read -r -d '' file; do
case "${file}" in
frontend/package-lock.json|frontend/pnpm-lock.yaml)
# Package managers own lockfile formatting and verify it during install.
;;
backend/*.py)
python_files+=("./${file#backend/}")
;;
frontend/*.js|frontend/*.jsx|frontend/*.ts|frontend/*.tsx|frontend/*.mjs|frontend/*.cjs)
relative="./${file#frontend/}"
if [[ ${relative} != "./next-env.d.ts" ]]; then
eslint_files+=("${relative}")
prettier_files+=("${relative}")
fi
;;
frontend/*.json|frontend/*.css|frontend/*.scss|frontend/*.md|frontend/*.yaml|frontend/*.yml)
prettier_files+=("./${file#frontend/}")
;;
.githooks/*|tools/quality/*.sh)
shell_files+=("${REPO_ROOT}/${file}")
;;
esac
done < <(collect_changed_files)
if (( ${#shell_files[@]} )); then
log "Bash syntax: changed hook and quality scripts"
bash -n "${shell_files[@]}"
fi
if (( ${#python_files[@]} )); then
python="$(python_command)"
log "Ruff: changed Python files"
(
cd "${BACKEND_DIR}"
"${python}" -m ruff check "${python_files[@]}"
"${python}" -m ruff format --check "${python_files[@]}"
)
fi
if (( ${#eslint_files[@]} || ${#prettier_files[@]} )); then
read -r -a npm <<<"$(npm_command)"
fi
if (( ${#eslint_files[@]} )); then
log "ESLint: changed frontend files"
(
cd "${FRONTEND_DIR}"
"${FRONTEND_DIR}/node_modules/.bin/eslint" \
--max-warnings=0 "${eslint_files[@]}"
)
fi
if (( ${#prettier_files[@]} )); then
log "Prettier: changed frontend files"
(
cd "${FRONTEND_DIR}"
"${FRONTEND_DIR}/node_modules/.bin/prettier" \
--check "${prettier_files[@]}"
)
fi
}
run_application_checks() {
local python
python="$(python_command)"
log "Django system and migration checks"
(
cd "${BACKEND_DIR}"
DJANGO_SETTINGS_MODULE=eusrr_backend.settings_test \
"${python}" manage.py check
DJANGO_SETTINGS_MODULE=eusrr_backend.settings_test \
"${python}" manage.py makemigrations --check --dry-run
)
}
run_test_suite() {
local python
local -a npm=()
python="$(python_command)"
read -r -a npm <<<"$(npm_command)"
log "Backend test suite"
(
cd "${BACKEND_DIR}"
"${python}" -m pytest -q
)
log "Frontend test suite"
(
cd "${FRONTEND_DIR}"
"${npm[@]}" test
)
log "Frontend production build"
(
cd "${FRONTEND_DIR}"
"${npm[@]}" run build
)
}
run_postgresql_migration_check() {
local python container port
python="$(python_command)"
require_command docker
container="eusrr-quality-postgres-$$"
cleanup_postgres() {
docker rm --force "${container}" >/dev/null 2>&1 || true
}
trap cleanup_postgres EXIT HUP INT TERM
log "Fresh PostgreSQL migration"
docker run --rm --detach --name "${container}" \
--env POSTGRES_DB=eusrr_quality \
--env POSTGRES_USER=postgres \
--env POSTGRES_PASSWORD=quality \
--publish 127.0.0.1::5432 postgres:17-alpine >/dev/null
port="$(docker port "${container}" 5432/tcp | sed 's/.*://')"
for _attempt in {1..30}; do
if docker exec "${container}" pg_isready \
--username postgres --dbname eusrr_quality >/dev/null 2>&1; then
break
fi
sleep 1
done
docker exec "${container}" pg_isready \
--username postgres --dbname eusrr_quality >/dev/null
(
cd "${BACKEND_DIR}"
USE_SQLITE=false \
LDAP_WRITE_ENABLED=false \
LDAP_ENABLED=false \
DB_HOST=127.0.0.1 \
DB_PORT="${port}" \
POSTGRES_DB=eusrr_quality \
POSTGRES_USER=postgres \
POSTGRES_PASSWORD=quality \
"${python}" manage.py migrate --noinput
)
cleanup_postgres
trap - EXIT HUP INT TERM
}
run_release_lint() {
local python
local -a npm=()
python="$(python_command)"
read -r -a npm <<<"$(npm_command)"
log "Ruff: complete backend"
(
cd "${BACKEND_DIR}"
"${python}" -m ruff check .
"${python}" -m ruff format --check .
)
log "ESLint and Prettier: complete frontend"
(
cd "${FRONTEND_DIR}"
"${npm[@]}" run lint -- --max-warnings=0
"${FRONTEND_DIR}/node_modules/.bin/prettier" \
--check . --ignore-path "${REPO_ROOT}/.prettierignore"
)
}
run_changed_quality
run_application_checks
if [[ ${MODE} == "release" ]]; then
run_release_lint
fi
if [[ ${MODE} == "test" || ${MODE} == "release" ]]; then
run_test_suite
run_postgresql_migration_check
fi
log "Quality gate '${MODE}' passed"