From cff78d123053fb5dd2ccd8c879e83d55dfd58eea Mon Sep 17 00:00:00 2001 From: Frank Harris Date: Fri, 12 Jun 2026 14:40:11 -0500 Subject: [PATCH] add workflows --- .forgejo/workflows/project-report.yml | 18 ++ .forgejo/workflows/validate.yml | 20 +- docs/AUTOMATION.md | 108 +++++++++ reports/.gitkeep | 1 + tools/project-report.sh | 309 ++++++++++++++++++++++++++ tools/validate-code.sh | 264 ++++++++++++++++++++++ 6 files changed, 704 insertions(+), 16 deletions(-) create mode 100644 .forgejo/workflows/project-report.yml create mode 100644 docs/AUTOMATION.md create mode 100644 reports/.gitkeep create mode 100755 tools/project-report.sh create mode 100755 tools/validate-code.sh diff --git a/.forgejo/workflows/project-report.yml b/.forgejo/workflows/project-report.yml new file mode 100644 index 0000000..9c9767e --- /dev/null +++ b/.forgejo/workflows/project-report.yml @@ -0,0 +1,18 @@ +name: Project Report + +on: + push: + pull_request: + +jobs: + project-report: + runs-on: linux-dev + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Generate project report + run: | + chmod +x tools/project-report.sh + ./tools/project-report.sh diff --git a/.forgejo/workflows/validate.yml b/.forgejo/workflows/validate.yml index 4d38a8e..19e80a9 100644 --- a/.forgejo/workflows/validate.yml +++ b/.forgejo/workflows/validate.yml @@ -1,4 +1,3 @@ - name: Validate Code on: @@ -10,21 +9,10 @@ jobs: runs-on: linux-dev steps: - - name: Checkout repo + - name: Checkout repository uses: actions/checkout@v4 - - name: Check PHP syntax + - name: Run validation run: | - if find . -name "*.php" | grep -q .; then - find . -name "*.php" -print0 | xargs -0 -n1 php -l - else - echo "No PHP files found." - fi - - - name: Check C# build - run: | - if find . -name "*.csproj" | grep -q .; then - dotnet build - else - echo "No C# project found." - fi \ No newline at end of file + chmod +x tools/validate-code.sh + ./tools/validate-code.sh diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md new file mode 100644 index 0000000..9a57b64 --- /dev/null +++ b/docs/AUTOMATION.md @@ -0,0 +1,108 @@ +# Automation + +## Purpose + +This repository includes two Forgejo Actions workflows for reusable customer-project automation: + +- `validate.yml`: runs repository validation on push and pull request. +- `project-report.yml`: generates best-effort project analysis reports on push and pull request. + +## Workflows + +### `validate.yml` + +- runs on `push` +- runs on `pull_request` +- uses the `linux-dev` runner label +- checks out the repository +- runs `tools/validate-code.sh` +- fails when validation finds real errors + +### `project-report.yml` + +- runs on `push` +- runs on `pull_request` +- uses the `linux-dev` runner label +- checks out the repository +- runs `tools/project-report.sh` +- regenerates reports in `reports/` +- does not fail because analysis finds issues or uncertainty +- only fails if the script itself crashes + +## Scripts + +### `tools/validate-code.sh` + +This script recursively scans the repository while excluding common generated or dependency folders such as: + +- `.git` +- `.forgejo` +- `node_modules` +- `vendor` +- `Library` +- `Temp` +- `Logs` +- `obj` +- `bin` + +When the necessary tools are installed, it validates: + +- PHP with `php -l` +- Python with `python3 -m py_compile` +- Ruby with `ruby -c` +- Perl with `perl -c` +- Shell with `bash -n` +- JavaScript with `node --check` +- JSON with `jq` +- YAML with `yamllint` +- HTML with `tidy` +- C# with `dotnet build` +- C/C++ CMake projects with `cmake` configure validation + +It writes `reports/errors.md` with: + +- validation summary +- files checked +- warnings +- errors +- detailed per-file results + +### `tools/project-report.sh` + +This script generates: + +- `reports/project-flow.md` +- `reports/file-summary.md` +- `reports/languages.md` + +It performs best-effort analysis for: + +- language counts +- likely entry points +- possible startup files +- framework detection +- database detection +- environment files +- config files +- Docker-related files +- probable application flow + +The wording is intentionally cautious. It does not claim certainty. + +## Reuse In Future Customer Repositories + +To reuse this automation in another repository: + +1. Copy `.forgejo/workflows/validate.yml` +2. Copy `.forgejo/workflows/project-report.yml` +3. Copy `tools/validate-code.sh` +4. Copy `tools/project-report.sh` +5. Create `reports/.gitkeep` +6. Keep the exclude list if the repository includes generated or dependency folders +7. Review language and framework detection rules for project-specific needs + +## Notes + +- Reports are regenerated on every run. +- Validation skips checks gracefully when required tools are not installed. +- The automation is additive and does not modify customer source files. diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/reports/.gitkeep @@ -0,0 +1 @@ + diff --git a/tools/project-report.sh b/tools/project-report.sh new file mode 100755 index 0000000..87f0280 --- /dev/null +++ b/tools/project-report.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPORT_DIR="$ROOT_DIR/reports" +FLOW_REPORT="$REPORT_DIR/project-flow.md" +FILE_REPORT="$REPORT_DIR/file-summary.md" +LANG_REPORT="$REPORT_DIR/languages.md" + +EXCLUDES=( + ".git" + ".forgejo" + "node_modules" + "vendor" + "Library" + "Temp" + "Logs" + "obj" + "bin" +) + +mkdir -p "$REPORT_DIR" + +have_tool() { + command -v "$1" >/dev/null 2>&1 +} + +collect_files() { + local find_args=("$ROOT_DIR") + local exclude + + if ((${#EXCLUDES[@]} > 0)); then + find_args+=("(") + for exclude in "${EXCLUDES[@]}"; do + find_args+=(-name "$exclude" -o) + done + unset 'find_args[${#find_args[@]}-1]' + find_args+=(")" -prune -o) + fi + + find_args+=(-type f -print0) + find "${find_args[@]}" +} + +relative_path() { + local path="$1" + path="${path#"$ROOT_DIR"/}" + printf '%s' "$path" +} + +search_tree() { + local pattern="$1" + if have_tool rg; then + rg -qi "$pattern" "$ROOT_DIR" --glob '!node_modules/**' --glob '!vendor/**' --glob '!.git/**' --glob '!Library/**' --glob '!Temp/**' --glob '!Logs/**' --glob '!obj/**' --glob '!bin/**' + else + grep -RqiE --exclude-dir=.git --exclude-dir=.forgejo --exclude-dir=node_modules --exclude-dir=vendor --exclude-dir=Library --exclude-dir=Temp --exclude-dir=Logs --exclude-dir=obj --exclude-dir=bin "$pattern" "$ROOT_DIR" + fi +} + +count_matches() { + local pattern="$1" + find "$ROOT_DIR" -path "$ROOT_DIR/.git" -prune -o -type f -name "$pattern" -print | wc -l | tr -d ' ' +} + +declare -A LANGUAGE_COUNTS=( + ["PHP"]=0 + ["JavaScript"]=0 + ["TypeScript"]=0 + ["Python"]=0 + ["C#"]=0 + ["C++"]=0 + ["Ruby"]=0 + ["Perl"]=0 + ["HTML"]=0 + ["CSS"]=0 + ["Shell"]=0 +) + +ENTRY_POINTS=() +STARTUP_SCRIPTS=() +TOP_LEVEL_DIRS=() +ENV_FILES=() +CONFIG_FILES=() +DOCKER_FILES=() +DB_CONFIGS=() + +detect_entry_or_startup() { + local file="$1" + local base + base="$(basename "$file")" + + case "$base" in + index.php|index.html|index.htm|main.py|app.py|server.py|Program.cs|Main.cs|main.cpp|main.c) + ENTRY_POINTS+=("$(relative_path "$file")") + ;; + start*.sh|launch*.sh|run*.sh|startup*.sh|*.bat|*.cmd) + STARTUP_SCRIPTS+=("$(relative_path "$file")") + ;; + esac +} + +detect_config_files() { + local file="$1" + local rel + rel="$(relative_path "$file")" + + case "$(basename "$file")" in + .env|.env.*) + ENV_FILES+=("$rel") + ;; + Dockerfile|docker-compose.yml|docker-compose.yaml|compose.yml|compose.yaml) + DOCKER_FILES+=("$rel") + ;; + esac + + case "$file" in + *.conf|*.config|*.ini|*.env|*.yaml|*.yml|*.json|*config*.php|*config*.py|*config*.rb) + CONFIG_FILES+=("$rel") + ;; + esac + + case "$file" in + *database*|*db*config*|*mysql*|*pgsql*|*postgres*|*sqlite*|*mongo*) + DB_CONFIGS+=("$rel") + ;; + esac +} + +classify_language() { + local file="$1" + case "$file" in + *.php) LANGUAGE_COUNTS["PHP"]=$((LANGUAGE_COUNTS["PHP"] + 1)) ;; + *.js|*.mjs|*.cjs) LANGUAGE_COUNTS["JavaScript"]=$((LANGUAGE_COUNTS["JavaScript"] + 1)) ;; + *.ts|*.tsx) LANGUAGE_COUNTS["TypeScript"]=$((LANGUAGE_COUNTS["TypeScript"] + 1)) ;; + *.py) LANGUAGE_COUNTS["Python"]=$((LANGUAGE_COUNTS["Python"] + 1)) ;; + *.cs) LANGUAGE_COUNTS["C#"]=$((LANGUAGE_COUNTS["C#"] + 1)) ;; + *.cpp|*.cc|*.cxx|*.c|*.h|*.hpp) LANGUAGE_COUNTS["C++"]=$((LANGUAGE_COUNTS["C++"] + 1)) ;; + *.rb) LANGUAGE_COUNTS["Ruby"]=$((LANGUAGE_COUNTS["Ruby"] + 1)) ;; + *.pl|*.pm) LANGUAGE_COUNTS["Perl"]=$((LANGUAGE_COUNTS["Perl"] + 1)) ;; + *.html|*.htm) LANGUAGE_COUNTS["HTML"]=$((LANGUAGE_COUNTS["HTML"] + 1)) ;; + *.css) LANGUAGE_COUNTS["CSS"]=$((LANGUAGE_COUNTS["CSS"] + 1)) ;; + *.sh|*.bash) LANGUAGE_COUNTS["Shell"]=$((LANGUAGE_COUNTS["Shell"] + 1)) ;; + esac +} + +detect_frameworks() { + local frameworks=() + + [[ -f "$ROOT_DIR/artisan" ]] && frameworks+=("Laravel") + [[ -d "$ROOT_DIR/app/Config" ]] && frameworks+=("CodeIgniter") + [[ -f "$ROOT_DIR/wp-config.php" ]] && frameworks+=("WordPress") + [[ -f "$ROOT_DIR/manage.py" ]] && frameworks+=("Django") + [[ -f "$ROOT_DIR/app.py" ]] && search_tree "Flask" && frameworks+=("Flask") + [[ -f "$ROOT_DIR/package.json" ]] && frameworks+=("Node.js") + [[ -f "$ROOT_DIR/package.json" ]] && search_tree "\"express\"" && frameworks+=("Express") + [[ -f "$ROOT_DIR/package.json" ]] && search_tree "\"react\"" && frameworks+=("React") + [[ -f "$ROOT_DIR/package.json" ]] && search_tree "\"vue\"" && frameworks+=("Vue") + find "$ROOT_DIR" -path "$ROOT_DIR/.git" -prune -o -name '*.csproj' -type f -print | grep -q . 2>/dev/null && frameworks+=("ASP.NET") + find "$ROOT_DIR" -path "$ROOT_DIR/.git" -prune -o -name 'ProjectSettings.asset' -type f -print | grep -q . 2>/dev/null && frameworks+=("Unity") + [[ -d "$ROOT_DIR/src/Symfony" || -f "$ROOT_DIR/bin/console" ]] && frameworks+=("Symfony") + + printf '%s\n' "${frameworks[@]}" | awk 'NF && !seen[$0]++' +} + +detect_databases() { + local databases=() + + search_tree "mysql" && databases+=("MySQL") + search_tree "mariadb" && databases+=("MariaDB") + search_tree "postgres|postgresql|pgsql" && databases+=("PostgreSQL") + search_tree "sqlite" && databases+=("SQLite") + search_tree "mongodb|mongo" && databases+=("MongoDB") + + printf '%s\n' "${databases[@]}" | awk 'NF && !seen[$0]++' +} + +while IFS= read -r -d '' file; do + classify_language "$file" + detect_entry_or_startup "$file" + detect_config_files "$file" +done < <(collect_files) + +while IFS= read -r dir; do + TOP_LEVEL_DIRS+=("${dir#./}") +done < <(find "$ROOT_DIR" -mindepth 1 -maxdepth 1 -type d ! -name '.git' | sed "s#^$ROOT_DIR/##" | sort) + +FRAMEWORKS="$(detect_frameworks | awk 'NF { if (count++) printf ", "; printf "%s", $0 }')" +DATABASES="$(detect_databases | awk 'NF { if (count++) printf ", "; printf "%s", $0 }')" + +LIKELY_ENTRY="None detected" +if ((${#ENTRY_POINTS[@]} > 0)); then + LIKELY_ENTRY="${ENTRY_POINTS[0]}" +fi + +{ + printf '# Languages\n\n' + printf '| Language | Count |\n' + printf '|---|---:|\n' + for lang in "PHP" "JavaScript" "TypeScript" "Python" "C#" "C++" "Ruby" "Perl" "HTML" "CSS" "Shell"; do + printf '| %s | %s |\n' "$lang" "${LANGUAGE_COUNTS[$lang]}" + done +} >"$LANG_REPORT" + +{ + printf '# File Summary\n\n' + printf '## Top-Level Folders\n\n' + if ((${#TOP_LEVEL_DIRS[@]} == 0)); then + printf -- '- None detected\n' + else + printf -- '- %s\n' "${TOP_LEVEL_DIRS[@]}" + fi + + printf '\n## File Counts By Type\n\n' + printf '| Type | Count |\n' + printf '|---|---:|\n' + printf '| PHP | %s |\n' "${LANGUAGE_COUNTS["PHP"]}" + printf '| JavaScript | %s |\n' "${LANGUAGE_COUNTS["JavaScript"]}" + printf '| TypeScript | %s |\n' "${LANGUAGE_COUNTS["TypeScript"]}" + printf '| Python | %s |\n' "${LANGUAGE_COUNTS["Python"]}" + printf '| C# | %s |\n' "${LANGUAGE_COUNTS["C#"]}" + printf '| C++ | %s |\n' "${LANGUAGE_COUNTS["C++"]}" + printf '| Ruby | %s |\n' "${LANGUAGE_COUNTS["Ruby"]}" + printf '| Perl | %s |\n' "${LANGUAGE_COUNTS["Perl"]}" + printf '| HTML | %s |\n' "${LANGUAGE_COUNTS["HTML"]}" + printf '| CSS | %s |\n' "${LANGUAGE_COUNTS["CSS"]}" + printf '| Shell | %s |\n' "${LANGUAGE_COUNTS["Shell"]}" + + printf '\n## Largest Source Directories\n\n' + find "$ROOT_DIR" -mindepth 1 -maxdepth 2 -type d ! -path "$ROOT_DIR/.git*" ! -path "$ROOT_DIR/.forgejo*" -print0 \ + | while IFS= read -r -d '' dir; do + count="$(find "$dir" -type f | wc -l | tr -d ' ')" + printf '%s\t%s\n' "$count" "$(relative_path "$dir")" + done | sort -rn | head -n 10 | awk -F '\t' '{printf "- %s files: %s\n", $1, $2}' +} >"$FILE_REPORT" + +{ + printf '# Project Flow Report\n\n' + printf '## Likely Entry Point\n\n' + printf -- '- Likely entry point: %s\n' "$LIKELY_ENTRY" + + printf '\n## Possible Startup Files\n\n' + if ((${#ENTRY_POINTS[@]} == 0 && ${#STARTUP_SCRIPTS[@]} == 0)); then + printf -- '- No obvious startup files detected.\n' + else + if ((${#ENTRY_POINTS[@]} > 0)); then + printf -- '- Possible startup file: %s\n' "${ENTRY_POINTS[@]}" + fi + if ((${#STARTUP_SCRIPTS[@]} > 0)); then + printf -- '- Possible launch script: %s\n' "${STARTUP_SCRIPTS[@]}" + fi + fi + + printf '\n## Detected Frameworks\n\n' + if [[ -n "$FRAMEWORKS" ]]; then + IFS=',' read -r -a framework_items <<<"$FRAMEWORKS" + for item in "${framework_items[@]}"; do + printf -- '- %s\n' "$(echo "$item" | sed 's/^ *//; s/ *$//')" + done + else + printf -- '- No supported framework markers were detected.\n' + fi + + printf '\n## Detected Databases\n\n' + if [[ -n "$DATABASES" ]]; then + IFS=',' read -r -a database_items <<<"$DATABASES" + for item in "${database_items[@]}"; do + printf -- '- %s\n' "$(echo "$item" | sed 's/^ *//; s/ *$//')" + done + else + printf -- '- No database markers were detected.\n' + fi + + printf '\n## Configuration Files\n\n' + if ((${#ENV_FILES[@]} > 0)); then + printf -- '- Possible .env files: %s\n' "${ENV_FILES[@]}" + else + printf -- '- No .env files detected.\n' + fi + if ((${#DOCKER_FILES[@]} > 0)); then + printf -- '- Docker-related files: %s\n' "${DOCKER_FILES[@]}" + else + printf -- '- No Docker-related files detected.\n' + fi + if ((${#DB_CONFIGS[@]} > 0)); then + printf -- '- Possible database config files: %s\n' "${DB_CONFIGS[@]}" + else + printf -- '- No obvious database config files detected.\n' + fi + + printf '\n## Important Files To Review\n\n' + if ((${#ENTRY_POINTS[@]} > 0)); then + printf -- '- %s\n' "${ENTRY_POINTS[@]}" + fi + if ((${#CONFIG_FILES[@]} > 0)); then + printf -- '- %s\n' "${CONFIG_FILES[@]:0:10}" + else + printf -- '- No common config files detected.\n' + fi + + printf '\n## Probable Application Flow\n\n' + printf 'This is a best-effort analysis only.\n\n' + printf -- '- Probable application flow starts near `%s`.\n' "$LIKELY_ENTRY" + printf -- '- Likely supporting behavior is configured through nearby config files, environment files, or workflow files.\n' + printf -- '- If framework markers are present, startup and routing probably follow that framework'"'"'s conventions.\n' + printf -- '- If no framework markers are present, the repository likely uses a simpler file-based startup flow.\n' +} >"$FLOW_REPORT" + +exit 0 diff --git a/tools/validate-code.sh b/tools/validate-code.sh new file mode 100755 index 0000000..d57a0b1 --- /dev/null +++ b/tools/validate-code.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPORT_DIR="$ROOT_DIR/reports" +ERROR_REPORT="$REPORT_DIR/errors.md" + +FILES_CHECKED=0 +WARNINGS=0 +ERRORS=0 + +DETAILS=() + +EXCLUDES=( + ".git" + ".forgejo" + "node_modules" + "vendor" + "Library" + "Temp" + "Logs" + "obj" + "bin" +) + +mkdir -p "$REPORT_DIR" + +escape_md() { + printf '%s' "$1" | sed 's/|/\\|/g' +} + +have_tool() { + command -v "$1" >/dev/null 2>&1 +} + +add_warning() { + local message="$1" + WARNINGS=$((WARNINGS + 1)) + DETAILS+=("- Warning: $(escape_md "$message")") +} + +add_error() { + local message="$1" + ERRORS=$((ERRORS + 1)) + DETAILS+=("- Error: $(escape_md "$message")") +} + +add_ok() { + local message="$1" + DETAILS+=("- OK: $(escape_md "$message")") +} + +relative_path() { + local path="$1" + path="${path#"$ROOT_DIR"/}" + printf '%s' "$path" +} + +join_by() { + local delimiter="$1" + shift || true + local first=1 + local item + for item in "$@"; do + if [[ $first -eq 1 ]]; then + printf '%s' "$item" + first=0 + else + printf '%s%s' "$delimiter" "$item" + fi + done +} + +collect_files() { + local find_args=("$ROOT_DIR") + local exclude + + if ((${#EXCLUDES[@]} > 0)); then + find_args+=("(") + for exclude in "${EXCLUDES[@]}"; do + find_args+=(-name "$exclude" -o) + done + unset 'find_args[${#find_args[@]}-1]' + find_args+=(")" -prune -o) + fi + + find_args+=(-type f -print0) + find "${find_args[@]}" +} + +run_check() { + local label="$1" + local file="$2" + shift 2 + + FILES_CHECKED=$((FILES_CHECKED + 1)) + + if "$@" >/tmp/runlevel-validate.out 2>/tmp/runlevel-validate.err; then + add_ok "$label passed: $(relative_path "$file")" + else + local stdout stderr combined + stdout="$(tr '\n' ' ' 0 || ${#csproj_files[@]} > 0)); then + if have_tool dotnet; then + if ((${#sln_files[@]} > 0)); then + for file in "${sln_files[@]}"; do + run_check "dotnet build" "$file" dotnet build "$file" --nologo --verbosity minimal + done + else + for file in "${csproj_files[@]}"; do + run_check "dotnet build" "$file" dotnet build "$file" --nologo --verbosity minimal + done + fi + else + add_warning "dotnet not installed; skipping C# validation." + fi + fi + + if [[ -f "$cmake_file" ]]; then + if have_tool cmake; then + FILES_CHECKED=$((FILES_CHECKED + 1)) + tmp_dir="$(mktemp -d)" + if cmake -S "$ROOT_DIR" -B "$tmp_dir" >/tmp/runlevel-validate.out 2>/tmp/runlevel-validate.err; then + add_ok "cmake configure passed: CMakeLists.txt" + else + local stdout stderr combined + stdout="$(tr '\n' ' ' "$ERROR_REPORT" + +rm -f /tmp/runlevel-validate.out /tmp/runlevel-validate.err + +if ((ERRORS > 0)); then + exit 1 +fi + +exit 0