Add local quality gates and Git hooks

This commit is contained in:
lizerk 2026-09-07 18:26:32 +03:00
parent a65b9d77aa
commit bf0df3613d
10 changed files with 402 additions and 0 deletions

5
.githooks/pre-commit Executable file
View File

@ -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

20
.githooks/pre-push Executable file
View File

@ -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}"

7
.prettierignore Normal file
View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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"

View File

@ -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:

42
tools/quality/README.md Normal file
View File

@ -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.

6
tools/quality/install-hooks.sh Executable file
View File

@ -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}"

289
tools/quality/quality.sh Executable file
View File

@ -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"