#!/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)" port="${port%%$'\n'*}" port="${port##*:}" [[ ${port} =~ ^[0-9]+$ ]] 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"