From bf0df3613d2ffb24baf693dbcd3f48566d3c61a8 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 7 Sep 2026 18:26:32 +0300 Subject: [PATCH] Add local quality gates and Git hooks --- .githooks/pre-commit | 5 + .githooks/pre-push | 20 +++ .prettierignore | 7 + backend/requirements-ci.txt | 5 + frontend/package-lock.json | 17 ++ frontend/package.json | 1 + frontend/pnpm-lock.yaml | 10 ++ tools/quality/README.md | 42 +++++ tools/quality/install-hooks.sh | 6 + tools/quality/quality.sh | 289 +++++++++++++++++++++++++++++++++ 10 files changed, 402 insertions(+) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push create mode 100644 .prettierignore create mode 100644 backend/requirements-ci.txt create mode 100644 tools/quality/README.md create mode 100755 tools/quality/install-hooks.sh create mode 100755 tools/quality/quality.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..0dacefd9 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +exec "${repo_root}/tools/quality/quality.sh" quick --staged diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..d05f0ad2 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +mode="quick" + +while read -r _local_ref _local_sha remote_ref _remote_sha; do + case "${remote_ref}" in + refs/heads/master) + mode="release" + ;; + refs/heads/develop) + if [[ ${mode} != "release" ]]; then + mode="test" + fi + ;; + esac +done + +exec "${repo_root}/tools/quality/quality.sh" "${mode}" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3c8b23b8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +backend/static/** +frontend/.next/** +frontend/node_modules/** +frontend/public/pdf.worker.min.mjs +frontend/next-env.d.ts +frontend/package-lock.json +frontend/pnpm-lock.yaml diff --git a/backend/requirements-ci.txt b/backend/requirements-ci.txt new file mode 100644 index 00000000..7ff158c0 --- /dev/null +++ b/backend/requirements-ci.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +# Local and CI quality tooling. Keep this separate from production runtime +# dependencies while pinning the exact version used by the project hooks. +ruff==0.15.8 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5022cacb..665c444f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,6 +49,7 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.3.1", + "prettier": "3.6.2", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5" @@ -7753,6 +7754,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index bc92d31d..4e7f89aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -53,6 +53,7 @@ "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.3.1", + "prettier": "3.6.2", "tailwindcss": "^4", "tsx": "^4.23.12", "typescript": "^5" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c66c4102..1d19f4bd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: eslint-config-next: specifier: 16.3.1 version: 16.3.1(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + prettier: + specifier: 3.6.2 + version: 3.6.2 tailwindcss: specifier: ^4 version: 4.3.3 @@ -2708,6 +2711,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -5848,6 +5856,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.6.2: {} + process-nextick-args@2.0.1: {} prop-types@15.8.1: diff --git a/tools/quality/README.md b/tools/quality/README.md new file mode 100644 index 00000000..cfb64893 --- /dev/null +++ b/tools/quality/README.md @@ -0,0 +1,42 @@ +# Local quality gates + +The checks run entirely on the development computer. GitHub and Gitea are not +contacted, except by the `git push` that starts the `pre-push` hook. + +## Setup + +Install the project dependencies, then enable the versioned hooks for this +checkout: + +```bash +cd backend +python -m pip install -r requirements-ci.txt +cd ../frontend +npm ci +cd .. +./tools/quality/install-hooks.sh +``` + +Each clone or independently configured worktree needs the one-time activation +command. Merely receiving `.githooks` through a mirror does not activate them. + +## Commands + +```bash +./tools/quality/quality.sh quick +./tools/quality/quality.sh test +./tools/quality/quality.sh release +``` + +- `quick` checks formatting, lint, Django configuration and missing migrations. +- `test` also runs both test suites, the production frontend build and all + migrations on a fresh temporary PostgreSQL database. +- `release` first requires the complete repository to pass Ruff, ESLint and + Prettier, then runs everything from `test`. + +`pre-commit` runs `quick --staged`. It refuses partially staged files so the +checked content is exactly the content being committed. `pre-push` chooses +`test` for `develop`, `release` for `master`, and `quick` for other branches. + +PostgreSQL validation requires Docker with the `postgres:17-alpine` image. The +temporary container is removed after the check. diff --git a/tools/quality/install-hooks.sh b/tools/quality/install-hooks.sh new file mode 100755 index 00000000..276eecaa --- /dev/null +++ b/tools/quality/install-hooks.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +git -C "${repo_root}" config --local core.hooksPath .githooks +printf 'Git hooks enabled for %s\n' "${repo_root}" diff --git a/tools/quality/quality.sh b/tools/quality/quality.sh new file mode 100755 index 00000000..41781e3a --- /dev/null +++ b/tools/quality/quality.sh @@ -0,0 +1,289 @@ +#!/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"