diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ed934e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + +jobs: + shellcheck: + name: Shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get install -y shellcheck + - name: Run shellcheck + run: | + shellcheck -S warning config.sh coordinator.sh worker.sh summary.sh summary-morning.sh lesson-generator.sh install.sh + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Make scripts executable + run: chmod +x *.sh tests/*.sh + - name: Run tests + run: ./tests/test-nightshift.sh + + language-check: + name: English Only + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for non-English strings + run: | + GERMAN=$(grep -rlE 'ä|ö|ü|ß' --include='*.sh' . || true) + if [[ -n "$GERMAN" ]]; then + echo "Files with German characters:" + echo "$GERMAN" + grep -rnE 'ä|ö|ü|ß' --include='*.sh' . + exit 1 + fi + echo "All clear — no German strings found." diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c98aa0e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to Night Shift will be documented in this file. + +## [0.3.0] - 2026-08-02 + +### Added +- Test suite (`tests/test-nightshift.sh`) covering config parsing, helper functions, lock mechanism, task categories, schema validation, and language checks +- GitHub Actions CI with shellcheck, tests, and English-only enforcement +- Example files: morning report, crontab configurations, multi-repo setup +- Architecture diagram in README +- Comparison table: Night Shift vs Devin vs CodeRabbit vs Sweep +- FAQ section in README +- Extending Night Shift guide for custom task categories + +### Changed +- README rewritten for competitive positioning and clarity +- Worker resolves repo paths from REPOS array (supports absolute paths) +- Improved stash handling with recovery instructions on failure + +## [0.2.0] - 2026-08-01 + +### Added +- Lesson generator for educational content from completed fixes +- Morning summary with automatic catch-up for missed summaries +- ntfy.sh notification support +- Install script with dependency checks and cron setup +- Security fix fallback: individual package upgrades when `npm audit fix` is ineffective +- Build verification (Next.js) after dependency updates +- RAM monitoring with configurable threshold +- Task timeout with TERM signal handling +- LLM output length guard for docs fixes + +### Changed +- Worker stashes uncommitted changes instead of skipping repos +- Security fixes include TypeScript compilation check with rollback + +## [0.1.0] - 2026-07-25 + +### Added +- Initial release +- Coordinator with multi-repo scanning +- Worker with lint-fix, type-fix, security, docs categories +- PostgreSQL schema for runs, tasks, lessons +- Ollama integration for local LLM analysis +- Claude API support (optional) +- Branch-per-fix workflow with backup tags +- Lockfile-based concurrency prevention diff --git a/README.md b/README.md index c15f5c8..7b950a4 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,236 @@ # Night Shift -**Your code improves while you sleep.** +**Your codebase improves while you sleep.** -Night Shift is an autonomous code improvement system that runs overnight and fixes lint errors, TypeScript issues, security vulnerabilities, and documentation gaps across your repositories. Every morning you wake up to a summary of what changed and why. +Night Shift scans your repositories overnight, finds lint errors, TypeScript issues, security vulnerabilities, and documentation gaps — fixes them automatically, commits each fix to its own branch, and sends you a morning summary. Self-hosted, free, runs with your own LLM. + +[![CI](https://github.com/FvdHMBAI/nightshift/actions/workflows/ci.yml/badge.svg)](https://github.com/FvdHMBAI/nightshift/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) ``` -┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ -│ Coordinator │────▶│ Scanner │────▶│ Tasks │────▶│ Workers │ -│ (cron) │ │ per repo │ │ (DB) │ │ parallel │ -└─────────────┘ └──────────┘ └──────────┘ └────┬─────┘ - │ - ┌──────────┐ ┌──────────┐ │ - │ Summary │◀────│ Commits │◀─────────┘ - │ (notify) │ │ (branch) │ - └──────────┘ └──────────┘ + Night Shift Architecture + + ┌────────────────────────────────────────────────────────────────────┐ + │ COORDINATOR (cron) │ + │ Runs nightly · checks RAM · creates run record · orchestrates │ + └───────────┬──────────────────────────────────────┬────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────────┐ + │ SCANNER (per repo) │ │ WORKER POOL (parallel) │ + │ │ │ │ + │ • ESLint errors │ tasks │ Worker 1: lint-fix │ + │ • TypeScript issues │──────────▶ │ Worker 2: security │ + │ • npm audit │ (DB) │ Worker 3: type-fix │ + │ • Missing tests │ │ │ + │ • Thin docs │ │ Each worker: │ + └──────────────────────┘ │ 1. Create branch │ + │ 2. Apply fix │ + │ 3. Verify (tsc) │ + │ 4. Commit + push │ + │ 5. Rollback on failure │ + └───────────┬──────────────┘ + │ + ┌─────────────────────────────────────┘ + ▼ + ┌─────────────────────┐ ┌─────────────────────┐ + │ LESSON GENERATOR │ │ MORNING SUMMARY │ + │ (optional, Pro) │ │ │ + │ │ │ • ntfy notification │ + │ Explains the WHY │ │ • DB summary record │ + │ behind each fix │ │ • Run statistics │ + └──────────────────────┘ └──────────────────────┘ ``` +## Why Night Shift? + +| | Night Shift | Devin | CodeRabbit | Sweep (dead) | +|---|---|---|---|---| +| **Cost** | Free (self-hosted) | $500/mo | $15/mo+ | Shut down | +| **Your data** | Stays on your server | Sent to cloud | Sent to cloud | — | +| **LLM choice** | Ollama / Claude / GPT | Proprietary | Proprietary | — | +| **Approach** | Overnight batch fixes | Interactive agent | PR review only | Issue→PR | +| **What it fixes** | Lint, types, security, docs | Everything (slowly) | Nothing (reviews only) | Issues | +| **Safety** | Branch per fix, auto-rollback | Full repo access | Read-only | Full access | +| **Setup** | 5 minutes | Account + billing | GitHub app | — | + +**Night Shift is not trying to be Devin.** It handles the boring, repetitive maintenance that piles up — the kind of work nobody wants to do but everybody benefits from. It runs when you're asleep, uses your own LLM, and every fix lands on its own branch for you to review. + ## What it fixes -| Category | What it does | How | -|----------|-------------|-----| -| **Lint** | Auto-fixes ESLint errors | `eslint --fix` | -| **Types** | Fixes TypeScript compilation errors | LLM-assisted analysis | -| **Security** | Patches npm vulnerabilities | `npm audit fix` + individual upgrades | -| **Docs** | Expands thin README files | LLM-generated content | +| Category | Detection | Fix method | Verification | +|----------|-----------|------------|--------------| +| **Lint** | `eslint --quiet` | `eslint --fix` | Re-lint | +| **Types** | `tsc --noEmit` | LLM-assisted patch | `tsc --noEmit` re-check | +| **Security** | `npm audit` (high/critical) | `npm audit fix` + individual upgrades | TypeScript + build check | +| **Docs** | README < 20 lines | LLM-generated expansion | Length check | +| **Deps** | `npm outdated` | `npm update` (minor/patch only) | TypeScript + Next.js build | -Every fix is committed to its own branch (`nightshift/lint-fix-20260801-...`) so you can review before merging. +Every fix runs on its own branch (`nightshift/lint-fix-20260801-eslint-no-unused-vars`), so you always review before merging. ## Quick Start -### 1. Prerequisites +### Prerequisites -- **PostgreSQL** (13+) for task tracking -- **Ollama** with a model (default: `qwen3:8b`) for local LLM analysis -- **Node.js** repos with ESLint / TypeScript (for lint/type fixes) -- Linux with `bash`, `curl`, `jq`, `python3` +- **Linux** with bash 4+, curl, jq, python3 +- **PostgreSQL 13+** for task tracking +- **Ollama** (recommended) or Anthropic API key for LLM analysis +- **Node.js** repos with ESLint / TypeScript -### 2. Install +### Install ```bash git clone https://github.com/FvdHMBAI/nightshift.git cd nightshift -# Edit config.sh — add your repos and database connection -vim config.sh +# 1. Configure +cp config.sh config.local.sh # keep your settings separate +vim config.local.sh # add repos, DB connection, LLM -# Run installer (creates DB tables, cron jobs, checks dependencies) +# 2. Install (creates tables, cron jobs, checks deps) ./install.sh + +# 3. Test run +./coordinator.sh ``` -### 3. Configure +### Configuration -Edit `config.sh`: +Edit `config.sh` (or create `config.local.sh` that sources and overrides it): ```bash -# Your repositories +# Repositories to scan (absolute paths) REPOS=( "$HOME/projects/my-frontend" "$HOME/projects/my-api" "$HOME/projects/my-backend" ) -# Database (PostgreSQL) -NIGHTSHIFT_DB_URL="postgresql://postgres:postgres@localhost:5432/nightshift" - -# Or via Docker container -# NIGHTSHIFT_DB_MODE="docker" -# NIGHTSHIFT_DB_CONTAINER="my-postgres" +# Database +DB_MODE="postgres" # or "docker" +DB_URL="postgresql://user:pass@localhost:5432/nightshift" +# DB_CONTAINER="my-postgres" # if mode=docker -# LLM (Ollama is default, Claude is optional) -OLLAMA_MODEL="qwen3:8b" -# ANTHROPIC_API_KEY="sk-ant-..." # Optional: better type fixes +# LLM — Ollama is default (free, local) +OLLAMA_MODEL="qwen3:8b" # any Ollama model +# ANTHROPIC_API_KEY="sk-ant-..." # optional: better type/doc fixes -# Notifications (optional, ntfy.sh compatible) -# NIGHTSHIFT_NTFY_URL="https://ntfy.sh/my-nightshift" -``` - -### 4. Run - -Night Shift runs automatically via cron (22:00 nightly, 06:00 morning summary). +# Limits +MAX_TASKS=15 # max tasks per run +MAX_WORKERS=3 # parallel workers +MAX_TASK_DURATION=7200 # 2h timeout per task +MIN_FREE_RAM_MB=3072 # abort if < 3GB free -Manual run: -```bash -./coordinator.sh # Full scan + fix cycle -./summary-morning.sh # Generate summary for last run +# Notifications (ntfy.sh compatible) +# NTFY_URL="https://ntfy.sh/my-nightshift" ``` ## How it works -1. **Scan** — Coordinator checks each repo for ESLint errors, TypeScript issues, npm vulnerabilities, missing tests, and thin docs -2. **Classify** — Findings become tasks with risk levels (`low` / `medium` / `high`) -3. **Execute** — Workers pick up tasks (up to 3 in parallel), create branches, apply fixes -4. **Verify** — TypeScript compilation check after every fix. If it breaks, automatic rollback -5. **Report** — Summary via ntfy notification + database record +``` +22:00 Coordinator starts + ├── Check RAM, create run record + ├── For each repo: + │ ├── git fetch origin develop + │ ├── ESLint scan → findings + │ ├── TypeScript scan → findings + │ ├── npm audit → findings + │ ├── Test coverage check → findings + │ └── README length check → findings + │ + ├── Classify findings → tasks (low/medium/high risk) + │ + ├── Run workers (up to 3 parallel): + │ ├── Create nightshift/* branch + │ ├── Apply fix + │ ├── Verify (tsc --noEmit, build check) + │ ├── If broken → rollback, mark failed + │ ├── If clean → commit, push, mark completed + │ └── Generate lesson (optional) + │ + └── Generate summary → DB + ntfy notification + +06:00 Morning summary (catches any unfinished runs) +``` -### Safety +### Safety guarantees -- Every fix runs on its own branch — your main/develop stays untouched -- Backup tags created before any changes -- TypeScript compilation check after npm security fixes (rollback on failure) -- RAM monitoring — stops if memory gets low -- Task timeout (2h default) prevents runaway processes -- Lockfile prevents concurrent runs -- Uncommitted changes are stashed and restored +1. **Branch isolation** — every fix gets its own branch. Your main/develop is never touched. +2. **Backup tags** — created before any changes, recoverable with `git tag -l 'nightshift-backup/*'`. +3. **Compilation verification** — TypeScript check after every npm change. Breaks → automatic rollback. +4. **Stash protection** — uncommitted work is stashed before, restored after. +5. **RAM monitoring** — stops spawning workers if memory drops below threshold. +6. **Task timeout** — 2-hour default prevents runaway processes. +7. **Lockfile** — prevents concurrent coordinator runs. +8. **Risk classification** — high-risk tasks (security) are flagged, complex ones deferred to Claude. ## Database -Night Shift uses PostgreSQL to track runs, tasks, and lessons: +Night Shift uses PostgreSQL to track every run, task, and generated lesson: -```bash -# Apply schema -psql $NIGHTSHIFT_DB_URL -f schema.sql +```sql +-- Recent runs +SELECT started_at, tasks_created, tasks_completed, tasks_failed, summary +FROM nightshift_runs ORDER BY started_at DESC LIMIT 5; + +-- Failed tasks (what to investigate) +SELECT repo, category, title, error_message +FROM nightshift_tasks WHERE status = 'failed' +ORDER BY created_at DESC LIMIT 10; -# Check recent runs -psql $NIGHTSHIFT_DB_URL -c "SELECT started_at, tasks_created, tasks_completed, tasks_failed FROM nightshift_runs ORDER BY started_at DESC LIMIT 5" +-- Lessons generated +SELECT topic, concept_category, difficulty, created_at +FROM lessons ORDER BY created_at DESC LIMIT 10; ``` -## Pro Features +## Lesson Generator (Pro) + +Every completed fix generates a learning lesson explaining the programming concept behind the change — not just what was fixed, but why it matters. Stored in the database with topic, difficulty, code before/after, and exercises. -The lesson generator (`lesson-generator.sh`) creates educational content from every completed fix — explaining the programming concept behind the change. Requires an Anthropic API key for best results. +Works best with an Anthropic API key but falls back to Ollama. ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| -| `NIGHTSHIFT_DB_URL` | `postgresql://postgres:postgres@localhost:5432/nightshift` | PostgreSQL connection | +| `NIGHTSHIFT_DB_URL` | `postgresql://...localhost:5432/nightshift` | PostgreSQL connection | | `NIGHTSHIFT_DB_MODE` | `postgres` | `postgres` or `docker` | -| `NIGHTSHIFT_DB_CONTAINER` | — | Docker container name (if mode=docker) | -| `OLLAMA_URL` | `http://localhost:11434/api/generate` | Ollama API endpoint | -| `OLLAMA_MODEL` | `qwen3:8b` | Ollama model for analysis | -| `ANTHROPIC_API_KEY` | — | Claude API key (optional, Pro features) | -| `NIGHTSHIFT_NTFY_URL` | — | ntfy.sh notification endpoint | +| `NIGHTSHIFT_DB_CONTAINER` | — | Docker container (if mode=docker) | +| `NIGHTSHIFT_DB_NAME` | `nightshift` | Database name (docker mode) | +| `NIGHTSHIFT_DB_USER` | `postgres` | Database user (docker mode) | +| `OLLAMA_URL` | `http://localhost:11434/api/generate` | Ollama endpoint | +| `OLLAMA_MODEL` | `qwen3:8b` | Local LLM model | +| `ANTHROPIC_API_KEY` | — | Claude API (optional, Pro features) | +| `NIGHTSHIFT_CLAUDE_MODEL` | `claude-sonnet-4-6` | Claude model | +| `NIGHTSHIFT_NTFY_URL` | — | ntfy.sh notification URL | | `NIGHTSHIFT_LOG_DIR` | `/var/log/nightshift` | Log directory | | `NIGHTSHIFT_LESSON_DIR` | `./lessons` | Lesson output directory | +## Extending Night Shift + +Add custom task categories by: + +1. Adding a scanner in `coordinator.sh` → `scan_repo()` that outputs `repo|category|title|description` +2. Adding a handler in `worker.sh` → `case "$category" in your-category) execute_your_fix && success=true ;;` +3. That's it. The coordinator/worker/summary pipeline handles the rest. + +## FAQ + +**Can it break my code?** +Every fix runs on its own branch. If TypeScript compilation fails after a fix, the change is rolled back automatically. Your working branches are never modified. + +**Does it support monorepos?** +Yes. Add the monorepo root to `REPOS`. The scanner checks for ESLint, TypeScript, and npm audit at the configured paths. + +**What LLMs does it support?** +Any Ollama model (local, free) or Anthropic Claude (cloud, paid). The TypeScript fixer and doc generator use the LLM; lint and security fixes are deterministic tools. + +**How much does it cost to run?** +With Ollama: $0. With Claude API: typically $0.02-0.10 per run depending on findings. The system logs token usage per task. + +**Can I run it manually?** +Yes: `./coordinator.sh` runs a full cycle. `./summary-morning.sh` generates/sends the summary. + ## License MIT — see [LICENSE](LICENSE) diff --git a/examples/crontab b/examples/crontab new file mode 100644 index 0000000..715766f --- /dev/null +++ b/examples/crontab @@ -0,0 +1,19 @@ +# Night Shift Cron Jobs +# Install: crontab -e, then paste the lines below + +# Run coordinator every night at 22:00 +0 22 * * * /opt/nightshift/coordinator.sh >> /var/log/nightshift/cron.log 2>&1 + +# Generate morning summary at 06:00 +0 6 * * * /opt/nightshift/summary-morning.sh >> /var/log/nightshift/morning.log 2>&1 + +# --- Alternative schedules --- + +# Weekdays only (Mon-Fri) +# 0 22 * * 1-5 /opt/nightshift/coordinator.sh >> /var/log/nightshift/cron.log 2>&1 + +# Twice nightly (22:00 and 03:00) +# 0 22,3 * * * /opt/nightshift/coordinator.sh >> /var/log/nightshift/cron.log 2>&1 + +# Weekend deep scan (more tasks, more time) +# 0 20 * * 6 MAX_TASKS=30 MAX_TASK_DURATION=14400 /opt/nightshift/coordinator.sh >> /var/log/nightshift/cron.log 2>&1 diff --git a/examples/morning-report.md b/examples/morning-report.md new file mode 100644 index 0000000..bfa63e7 --- /dev/null +++ b/examples/morning-report.md @@ -0,0 +1,49 @@ +# Night Shift Morning Report + +**Run:** `3a7f1e2c-8d4b-4f6a-9c1e-5b3d7a2f8e4c` +**Started:** 2026-08-01 22:00:03 | **Completed:** 2026-08-01 23:47:12 +**Duration:** 1h 47m + +## Summary + +Night Shift scanned 4 repositories and found 8 issues. 5 tasks completed successfully, 1 failed (TypeScript fix could not pass compilation), 2 were deferred to Claude (test coverage generation). + +## Results + +| # | Repo | Category | Title | Status | Branch | +|---|------|----------|-------|--------|--------| +| 1 | my-frontend | lint-fix | ESLint: no-unused-vars in Header.tsx | Completed | `nightshift/lint-fix-20260801-eslint-no-unused-vars` | +| 2 | my-frontend | type-fix | TypeScript: TS2322 in api/users | Failed | — (rolled back) | +| 3 | my-api | security | 3 critical npm vulnerabilities (2 fixable) | Completed | `nightshift/security-20260801-3-critical-npm-vulner` | +| 4 | my-api | lint-fix | ESLint: no-explicit-any in middleware | Completed | `nightshift/lint-fix-20260801-eslint-no-explicit-any` | +| 5 | my-api | docs | README.md too short (12 lines) | Completed | `nightshift/docs-20260801-readme-md-too-short--12-l` | +| 6 | my-backend | security | 1 critical npm vulnerability (1 fixable) | Completed | `nightshift/security-20260801-1-critical-npm-vulner` | +| 7 | my-frontend | test-coverage | 4 API routes without tests | Deferred | — (needs Claude) | +| 8 | my-backend | test-coverage | 2 API routes without tests | Deferred | — (needs Claude) | + +## Branches Ready for Review + +```bash +# my-frontend +git log --oneline nightshift/lint-fix-20260801-eslint-no-unused-vars + +# my-api +git log --oneline nightshift/security-20260801-3-critical-npm-vulner +git log --oneline nightshift/lint-fix-20260801-eslint-no-explicit-any +git log --oneline nightshift/docs-20260801-readme-md-too-short--12-l + +# my-backend +git log --oneline nightshift/security-20260801-1-critical-npm-vulner +``` + +## Lessons Generated + +1. **Understanding `no-unused-vars`** (beginner) — Why dead code matters for bundle size and readability +2. **npm Security Advisories** (intermediate) — How `npm audit fix` resolves dependency chains without breaking semver + +## System + +- RAM: 5.2 GB free (minimum: 3 GB) +- Disk: 42 GB free +- Ollama: online (qwen3:8b) +- Tokens: 0 cloud, ~2400 local diff --git a/examples/multi-repo-config.sh b/examples/multi-repo-config.sh new file mode 100644 index 0000000..ece353d --- /dev/null +++ b/examples/multi-repo-config.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Example: Multi-repo Night Shift configuration +# Copy this to config.local.sh and adjust paths + +# Source defaults +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/config.sh" + +# ─── Your Repositories ─── +REPOS=( + "$HOME/projects/web-app" + "$HOME/projects/api-server" + "$HOME/projects/admin-panel" + "$HOME/projects/mobile-backend" + "$HOME/projects/shared-lib" +) + +# ─── Database (Docker example) ─── +DB_MODE="docker" +DB_CONTAINER="my-postgres" +DB_NAME="nightshift" +DB_USER="postgres" + +# ─── LLM ─── +# Local Ollama for routine tasks (free) +OLLAMA_MODEL="qwen3:8b" + +# Claude for complex type fixes and lesson generation (optional) +# export ANTHROPIC_API_KEY="sk-ant-..." + +# ─── Tuning ─── +MAX_TASKS=20 # More repos = more findings +MAX_WORKERS=4 # Adjust to your CPU cores +MAX_TASK_DURATION=3600 # 1h per task (faster timeout) +MIN_FREE_RAM_MB=4096 # 4 GB minimum for multiple workers + +# ─── Notifications ─── +NTFY_URL="https://ntfy.sh/my-nightshift-alerts" diff --git a/tests/test-nightshift.sh b/tests/test-nightshift.sh new file mode 100755 index 0000000..7a5acad --- /dev/null +++ b/tests/test-nightshift.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# Night Shift Test Suite +# Run: ./tests/test-nightshift.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# ─── Test Framework ─── + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILURES="" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +assert_eq() { + local expected="$1" actual="$2" msg="${3:-assertion}" + TESTS_RUN=$((TESTS_RUN + 1)) + if [[ "$expected" == "$actual" ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: ${msg}\n expected: '${expected}'\n actual: '${actual}'" + echo -e " ${RED}FAIL${NC} $msg" + return 0 + fi + echo -e " ${GREEN}PASS${NC} $msg" +} + +assert_contains() { + local haystack="$1" needle="$2" msg="${3:-contains assertion}" + TESTS_RUN=$((TESTS_RUN + 1)) + if echo "$haystack" | grep -qF "$needle"; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} $msg" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: ${msg}\n expected to contain: '${needle}'\n in: '${haystack}'" + echo -e " ${RED}FAIL${NC} $msg" + fi +} + +assert_not_empty() { + local value="$1" msg="${2:-not empty assertion}" + TESTS_RUN=$((TESTS_RUN + 1)) + if [[ -n "$value" ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} $msg" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: ${msg} (was empty)" + echo -e " ${RED}FAIL${NC} $msg" + fi +} + +assert_file_exists() { + local path="$1" msg="${2:-file exists}" + TESTS_RUN=$((TESTS_RUN + 1)) + if [[ -f "$path" ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} $msg" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: ${msg} (not found: $path)" + echo -e " ${RED}FAIL${NC} $msg" + fi +} + +assert_executable() { + local path="$1" msg="${2:-is executable}" + TESTS_RUN=$((TESTS_RUN + 1)) + if [[ -x "$path" ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} $msg" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: ${msg} (not executable: $path)" + echo -e " ${RED}FAIL${NC} $msg" + fi +} + +# ─── Test: File Structure ─── + +echo "" +echo "=== File Structure ===" + +assert_file_exists "$PROJECT_DIR/config.sh" "config.sh exists" +assert_file_exists "$PROJECT_DIR/coordinator.sh" "coordinator.sh exists" +assert_file_exists "$PROJECT_DIR/worker.sh" "worker.sh exists" +assert_file_exists "$PROJECT_DIR/summary.sh" "summary.sh exists" +assert_file_exists "$PROJECT_DIR/summary-morning.sh" "summary-morning.sh exists" +assert_file_exists "$PROJECT_DIR/lesson-generator.sh" "lesson-generator.sh exists" +assert_file_exists "$PROJECT_DIR/install.sh" "install.sh exists" +assert_file_exists "$PROJECT_DIR/schema.sql" "schema.sql exists" + +# ─── Test: Scripts are executable ─── + +echo "" +echo "=== Executability ===" + +for script in coordinator.sh worker.sh summary.sh summary-morning.sh lesson-generator.sh install.sh; do + assert_executable "$PROJECT_DIR/$script" "$script is executable" +done + +# ─── Test: Config Parsing ─── + +echo "" +echo "=== Config Parsing ===" + +# Source config in a subshell to test defaults +config_output=$(bash -c " + source '$PROJECT_DIR/config.sh' + echo \"MAX_TASKS=\$MAX_TASKS\" + echo \"MAX_WORKERS=\$MAX_WORKERS\" + echo \"MAX_TASK_DURATION=\$MAX_TASK_DURATION\" + echo \"MIN_FREE_RAM_MB=\$MIN_FREE_RAM_MB\" + echo \"DB_MODE=\$DB_MODE\" + echo \"OLLAMA_MODEL=\$OLLAMA_MODEL\" +" 2>/dev/null || echo "CONFIG_PARSE_FAILED") + +assert_contains "$config_output" "MAX_TASKS=15" "MAX_TASKS default is 15" +assert_contains "$config_output" "MAX_WORKERS=3" "MAX_WORKERS default is 3" +assert_contains "$config_output" "MAX_TASK_DURATION=7200" "MAX_TASK_DURATION default is 7200" +assert_contains "$config_output" "MIN_FREE_RAM_MB=3072" "MIN_FREE_RAM_MB default is 3072" +assert_contains "$config_output" "DB_MODE=postgres" "DB_MODE default is postgres" +assert_contains "$config_output" "OLLAMA_MODEL=qwen3:8b" "OLLAMA_MODEL default is qwen3:8b" + +# Test config override via env vars +override_output=$(bash -c " + export OLLAMA_MODEL='llama3:8b' + export NIGHTSHIFT_DB_MODE='docker' + source '$PROJECT_DIR/config.sh' + echo \"OLLAMA_MODEL=\$OLLAMA_MODEL\" + echo \"DB_MODE=\$DB_MODE\" +" 2>/dev/null || echo "OVERRIDE_FAILED") + +assert_contains "$override_output" "OLLAMA_MODEL=llama3:8b" "OLLAMA_MODEL overridable via env" +assert_contains "$override_output" "DB_MODE=docker" "DB_MODE overridable via env" + +# ─── Test: Helper Functions ─── + +echo "" +echo "=== Helper Functions ===" + +# sql_escape +escape_result=$(bash -c " + source '$PROJECT_DIR/config.sh' + sql_escape \"it's a test\" +" 2>/dev/null) +assert_eq "it''s a test" "$escape_result" "sql_escape escapes single quotes" + +escape_backslash=$(bash -c " + source '$PROJECT_DIR/config.sh' + sql_escape 'path\\\\to\\\\file' +" 2>/dev/null) +assert_eq 'path\\\\to\\\\file' "$escape_backslash" "sql_escape escapes backslashes" + +# log function +log_output=$(bash -c " + export NIGHTSHIFT_LOG_DIR='/tmp/nightshift-test-$$' + mkdir -p \$NIGHTSHIFT_LOG_DIR + source '$PROJECT_DIR/config.sh' + log 'test message' + cat \$NIGHTSHIFT_LOG_DIR/nightshift.log + rm -rf \$NIGHTSHIFT_LOG_DIR +" 2>/dev/null) +assert_contains "$log_output" "test message" "log() writes to log file" +assert_contains "$log_output" "[20" "log() includes timestamp" + +# check_ram (should succeed on any real machine) +ram_result=$(bash -c " + source '$PROJECT_DIR/config.sh' + MIN_FREE_RAM_MB=1 + if check_ram; then echo 'RAM_OK'; else echo 'RAM_LOW'; fi +" 2>/dev/null) +assert_eq "RAM_OK" "$ram_result" "check_ram passes with MIN_FREE_RAM_MB=1" + +ram_fail=$(bash -c " + export NIGHTSHIFT_LOG_DIR='/tmp/nightshift-test-ram-$$' + mkdir -p \$NIGHTSHIFT_LOG_DIR + source '$PROJECT_DIR/config.sh' + MIN_FREE_RAM_MB=999999 + if check_ram; then echo 'RAM_OK'; else echo 'RAM_LOW'; fi + rm -rf \$NIGHTSHIFT_LOG_DIR +" 2>/dev/null | tail -1) +assert_eq "RAM_LOW" "$ram_fail" "check_ram fails with MIN_FREE_RAM_MB=999999" + +# ─── Test: Lock Mechanism ─── + +echo "" +echo "=== Lock Mechanism ===" + +lock_result=$(bash -c " + export NIGHTSHIFT_LOG_DIR='/tmp/nightshift-test-$$' + mkdir -p \$NIGHTSHIFT_LOG_DIR + source '$PROJECT_DIR/config.sh' + LOCKFILE='/tmp/nightshift-test-lock-$$' + if acquire_lock; then echo 'ACQUIRED'; else echo 'BLOCKED'; fi + if [[ -f \$LOCKFILE ]]; then echo 'FILE_EXISTS'; fi + release_lock + if [[ -f \$LOCKFILE ]]; then echo 'STILL_EXISTS'; else echo 'CLEANED'; fi + rm -rf \$NIGHTSHIFT_LOG_DIR +" 2>/dev/null) +assert_contains "$lock_result" "ACQUIRED" "acquire_lock succeeds" +assert_contains "$lock_result" "FILE_EXISTS" "lockfile created" +assert_contains "$lock_result" "CLEANED" "release_lock removes lockfile" + +# Stale lock test +stale_result=$(bash -c " + export NIGHTSHIFT_LOG_DIR='/tmp/nightshift-test-$$' + mkdir -p \$NIGHTSHIFT_LOG_DIR + source '$PROJECT_DIR/config.sh' + LOCKFILE='/tmp/nightshift-test-lock-stale-$$' + echo 99999999 > \$LOCKFILE + if acquire_lock; then echo 'ACQUIRED_STALE'; else echo 'BLOCKED_STALE'; fi + release_lock + rm -rf \$NIGHTSHIFT_LOG_DIR +" 2>/dev/null) +assert_contains "$stale_result" "ACQUIRED_STALE" "acquire_lock removes stale lockfile" + +# ─── Test: Task Categories ─── + +echo "" +echo "=== Task Categories ===" + +# Check that worker.sh handles all documented categories +worker_categories=$(grep -oP "^\s+\K[a-z-]+\)" "$PROJECT_DIR/worker.sh" | tr -d ')' | sort) +for cat in lint-fix type-fix docs security test-coverage; do + TESTS_RUN=$((TESTS_RUN + 1)) + if echo "$worker_categories" | grep -qF "$cat"; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} worker handles category: $cat" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: worker missing category: $cat" + echo -e " ${RED}FAIL${NC} worker missing category: $cat" + fi +done + +# Check wildcard fallback +wildcard_fallback=$(grep -c '^\s*\*)' "$PROJECT_DIR/worker.sh" || echo "0") +TESTS_RUN=$((TESTS_RUN + 1)) +if [[ "$wildcard_fallback" -ge 1 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} worker has wildcard fallback for unknown categories" +else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: worker missing wildcard fallback" + echo -e " ${RED}FAIL${NC} worker missing wildcard fallback" +fi + +# ─── Test: Risk Classification ─── + +echo "" +echo "=== Risk Classification ===" + +# Verify risk classification in coordinator +risk_cases=$(grep -A2 'case.*category.*in' "$PROJECT_DIR/coordinator.sh" | grep -E 'security|architecture|complex-refactor' || echo "") +assert_not_empty "$risk_cases" "coordinator classifies security/architecture as higher risk" + +# ─── Test: Schema Validity ─── + +echo "" +echo "=== Schema ===" + +# Check all required tables +schema_content=$(cat "$PROJECT_DIR/schema.sql") +assert_contains "$schema_content" "nightshift_runs" "schema defines nightshift_runs table" +assert_contains "$schema_content" "nightshift_tasks" "schema defines nightshift_tasks table" +assert_contains "$schema_content" "lessons" "schema defines lessons table" +assert_contains "$schema_content" "learning_progress" "schema defines learning_progress table" +assert_contains "$schema_content" "lesson_chats" "schema defines lesson_chats table" + +# Check indexes +assert_contains "$schema_content" "idx_nightshift_tasks_run_id" "schema has run_id index" +assert_contains "$schema_content" "idx_nightshift_tasks_status" "schema has status index" + +# ─── Test: Shellcheck (if available) ─── + +echo "" +echo "=== Shellcheck ===" + +if command -v shellcheck &>/dev/null; then + shellcheck_pass=true + for script in config.sh coordinator.sh worker.sh summary.sh summary-morning.sh lesson-generator.sh install.sh; do + TESTS_RUN=$((TESTS_RUN + 1)) + sc_output=$(shellcheck -S warning "$PROJECT_DIR/$script" 2>&1 || true) + sc_errors=$(echo "$sc_output" | grep -c "^In " || echo "0") + if [[ "$sc_errors" -eq 0 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} shellcheck: $script" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: shellcheck: $script ($sc_errors warnings)" + echo -e " ${RED}FAIL${NC} shellcheck: $script ($sc_errors warnings)" + shellcheck_pass=false + fi + done +else + echo -e " ${YELLOW}SKIP${NC} shellcheck not installed" +fi + +# ─── Test: No German Strings in Code ─── + +echo "" +echo "=== Language Check (English only) ===" + +german_found=false +for script in config.sh coordinator.sh worker.sh summary.sh summary-morning.sh lesson-generator.sh install.sh; do + TESTS_RUN=$((TESTS_RUN + 1)) + german_lines=$(grep -cE 'ä|ö|ü|ß' "$PROJECT_DIR/$script" 2>/dev/null | tr -d '[:space:]' || echo "0") + german_lines="${german_lines:-0}" + if [[ "$german_lines" -eq 0 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} $script: no German strings" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: $script has $german_lines German string(s)" + echo -e " ${RED}FAIL${NC} $script: $german_lines German string(s)" + german_found=true + fi +done + +# ─── Test: Cleanup Function ─── + +echo "" +echo "=== Cleanup Functions ===" + +# Verify cleanup_branch exists and is called in worker +cleanup_calls=$(grep -c 'cleanup_branch' "$PROJECT_DIR/worker.sh" 2>/dev/null | tr -d '[:space:]' || echo "0") +TESTS_RUN=$((TESTS_RUN + 1)) +if [[ "$cleanup_calls" -ge 3 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} cleanup_branch called $cleanup_calls times in worker.sh" +else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: cleanup_branch only called $cleanup_calls times (expected >=3)" + echo -e " ${RED}FAIL${NC} cleanup_branch only called $cleanup_calls times" +fi + +# Verify EXIT trap in worker (may be multi-line) +trap_exists=$(grep -c 'EXIT' "$PROJECT_DIR/worker.sh" 2>/dev/null | tr -d '[:space:]' || echo "0") +TESTS_RUN=$((TESTS_RUN + 1)) +if [[ "$trap_exists" -ge 1 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} worker.sh has EXIT trap" +else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: worker.sh missing EXIT trap" + echo -e " ${RED}FAIL${NC} worker.sh missing EXIT trap" +fi + +# Verify TERM trap for timeout +term_trap=$(grep -c 'trap.*handle_timeout.*TERM' "$PROJECT_DIR/worker.sh" 2>/dev/null || echo "0") +TESTS_RUN=$((TESTS_RUN + 1)) +if [[ "$term_trap" -ge 1 ]]; then + TESTS_PASSED=$((TESTS_PASSED + 1)) + echo -e " ${GREEN}PASS${NC} worker.sh has TERM trap for timeout" +else + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILURES="${FAILURES}\n FAIL: worker.sh missing TERM trap for timeout" + echo -e " ${RED}FAIL${NC} worker.sh missing TERM trap" +fi + +# ─── Test: Summary Generation ─── + +echo "" +echo "=== Summary ===" + +# Verify summary.sh reads run data +summary_content=$(cat "$PROJECT_DIR/summary.sh") +assert_contains "$summary_content" "nightshift_runs" "summary.sh queries nightshift_runs" +assert_contains "$summary_content" "nightshift_tasks" "summary.sh queries nightshift_tasks" +assert_contains "$summary_content" "ntfy" "summary.sh supports ntfy notifications" + +# Verify morning summary handles edge cases +morning_content=$(cat "$PROJECT_DIR/summary-morning.sh") +assert_contains "$morning_content" "No runs found" "morning summary handles no-runs case" +assert_contains "$morning_content" "already has summary" "morning summary skips existing summaries" + +# ─── Results ─── + +echo "" +echo "============================================" +if [[ "$TESTS_FAILED" -eq 0 ]]; then + echo -e " ${GREEN}ALL $TESTS_RUN TESTS PASSED${NC}" +else + echo -e " ${RED}$TESTS_FAILED/$TESTS_RUN TESTS FAILED${NC}" + echo -e "$FAILURES" +fi +echo " Passed: $TESTS_PASSED Failed: $TESTS_FAILED Total: $TESTS_RUN" +echo "============================================" + +exit "$TESTS_FAILED"