diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..f4b4092 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "$(git rev-parse --show-toplevel)/scripts/pre-commit.sh" diff --git a/.github/workflows/assets/lib.sh b/.github/workflows/assets/lib.sh index 7462c9a..10e9f23 100644 --- a/.github/workflows/assets/lib.sh +++ b/.github/workflows/assets/lib.sh @@ -14,6 +14,21 @@ set_git_bot_config() { repo_exists() { gh repo view "$1/$2" &>/dev/null; } +# Returns 0 if org/repo has an open PR into local-{lang_code} with head matching +# "translation-{lang_code}-*"; 1 if none; 2 if the GitHub API check failed. +has_open_translation_pr() { + local org="$1" repo="$2" lang_code="$3" + local base_br="local-${lang_code}" + local output + if ! output=$(gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ + --jq ".[] | select(.headRefName | startswith(\"translation-${lang_code}-\")) | .headRefName" \ + 2>&1); then + echo " Error: could not list open PRs for $org/$repo (base=$base_br): $output" >&2 + return 2 + fi + [[ -n "$output" ]] +} + # ── Git clone helpers ──────────────────────────────────────────────── # Clone repo at branch/tag into $3. Pass "keep" as $4 to preserve .git. @@ -197,6 +212,22 @@ parse_list() { done } +# Parse "[.adoc, .md]" or '[".adoc",".md"]' into one extension per line. +parse_extensions() { + local s="$1" + s="${s//[[:space:]]/}"; [[ -z "$s" ]] && return + s="${s#[}"; s="${s%]}" + local result=() + IFS=',' read -ra parts <<< "$s" + for part in "${parts[@]}"; do + part="${part//\"/}"; part="${part//\'/}" + [[ -z "$part" ]] && continue + [[ "$part" == .* ]] || part=".${part}" + result+=("$part") + done + printf '%s\n' "${result[@]}" +} + # Return 0 when $1 is a well-formed Weblate language code. is_valid_lang_code() { local code="$1" diff --git a/.github/workflows/assets/translation.sh b/.github/workflows/assets/translation.sh new file mode 100644 index 0000000..86a2c6c --- /dev/null +++ b/.github/workflows/assets/translation.sh @@ -0,0 +1,116 @@ +# shellcheck shell=bash +# start-translation orchestration helpers. +# Source after env.sh and lib.sh. Requires globals: +# MODULE_ORG, MASTER_BRANCH, BOOST_ORG, BOOST_WORK, ORG_WORK, libs_ref, +# lang_codes_arr, add_or_update (associative), ORG_REPO_MISSING, META_MISSING, +# NO_DOC_PATHS, GITHUB_WORKSPACE. +# shellcheck disable=SC2034,SC2154 + +# Wipe dest_repo (except .git), copy pruned source, commit, push master only. +sync_repo_master() { + local dest_repo="$1" sub_clone="$2" libs_ref="$3" + find "$dest_repo" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + || return 2 + cp -r "$sub_clone/." "$dest_repo/" || return 2 + set_git_bot_config "$dest_repo" + git -C "$dest_repo" add -A || return 2 + if ! git -C "$dest_repo" diff --cached --quiet; then + git -C "$dest_repo" commit -m "Update the original documentation of $libs_ref" || return 2 + fi + git -C "$dest_repo" push origin "$MASTER_BRANCH" || return 2 +} + +# Merge master into local-{lang_code} and push. +update_local_merge_from_master() { + local repo_dir="$1" lang_code="$2" + local local_br="local-${lang_code}" + git -C "$repo_dir" fetch origin "$MASTER_BRANCH" || return 2 + git -C "$repo_dir" fetch origin "$local_br" || return 2 + git -C "$repo_dir" checkout -B "$local_br" "origin/$local_br" || return 2 + git -C "$repo_dir" merge "origin/$MASTER_BRANCH" || return 2 + git -C "$repo_dir" push origin "${local_br}:${local_br}" || return 2 +} + +# Create local-{lang_code} in a library mirror repo from master, with create-tag.yml. +ensure_local_branch_in_repo() { + local dest_repo="$1" sub_name="$2" lang_code="$3" + local local_br="local-${lang_code}" + if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then + echo " Branch $local_br already exists in $sub_name." >&2 + return 0 + fi + echo " Creating branch $local_br in $sub_name from $MASTER_BRANCH..." >&2 + git -C "$dest_repo" fetch origin "$MASTER_BRANCH" || return 2 + git -C "$dest_repo" checkout -B "$local_br" "origin/$MASTER_BRANCH" || return 2 + add_create_tag_workflow "$dest_repo" || return 2 + git -C "$dest_repo" push -u origin "$local_br" || return 2 + echo " Created branch $local_br." >&2 +} + +# Handle local-{lang_code} branch in a library mirror repo after master is synced. +# Returns 0 if submodule should be added to add_or_update[lang_code]; 1 if skipped (open PR); 2 on git failure. +process_local_branch() { + local dest_repo="$1" sub_name="$2" lang_code="$3" + local local_br="local-${lang_code}" + if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then + has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code" + case $? in + 0) echo " Open translation PR found for $sub_name ($local_br), skipping." >&2; return 1 ;; + 2) return 2 ;; + esac + update_local_merge_from_master "$dest_repo" "$lang_code" || return 2 + else + ensure_local_branch_in_repo "$dest_repo" "$sub_name" "$lang_code" || return 2 + fi + return 0 +} + +process_one_submodule() { + local sub_name="$1" doc_paths + + if ! repo_exists "$MODULE_ORG" "$sub_name"; then + ORG_REPO_MISSING+=("$sub_name") + echo " Error: $MODULE_ORG/$sub_name does not exist. Run add-submodules first." >&2 + return 2 + fi + + doc_paths=$(get_doc_paths "$sub_name" "$libs_ref") || { + META_MISSING+=("$sub_name") + echo " No libraries.json." >&2; return 2 + } + [[ -z "$doc_paths" ]] && { + NO_DOC_PATHS+=("$sub_name") + echo " No doc paths in metadata, skipping." >&2; return 1 + } + + local sub_clone="$BOOST_WORK/$sub_name" + clone_repo "https://github.com/${BOOST_ORG}/${sub_name}.git" \ + "$libs_ref" "$sub_clone" || { echo " Clone failed." >&2; return 2; } + + local -a paths_arr + mapfile -t paths_arr <<< "$doc_paths" + prune_to_doc_only "$sub_clone" "${paths_arr[@]}" + + local org_repo_url="https://github.com/${MODULE_ORG}/${sub_name}.git" + local dest_repo="$ORG_WORK/$sub_name" + clone_repo "$org_repo_url" "$MASTER_BRANCH" "$dest_repo" keep || { + echo " clone_repo failed." >&2; return 2 + } + + sync_repo_master "$dest_repo" "$sub_clone" "$libs_ref" || return 2 + + local any_added=0 rc + for lang_code in "${lang_codes_arr[@]}"; do + if process_local_branch "$dest_repo" "$sub_name" "$lang_code"; then + if [[ -n "${add_or_update[$lang_code]:-}" ]]; then + add_or_update["$lang_code"]+=" $sub_name" + else + add_or_update["$lang_code"]="$sub_name" + fi + any_added=1 + else + rc=$? + [[ $rc -eq 2 ]] && return 2 + fi + done + [[ $any_added -eq 1 ]] +} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index bf2013c..8328259 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,22 +14,20 @@ jobs: with: persist-credentials: false - - name: ShellCheck - run: | - set -euo pipefail - shellcheck -x \ - .github/workflows/assets/env.sh \ - .github/workflows/assets/lib.sh \ - scripts/*.sh + - name: ShellCheck and actionlint + run: scripts/lint.sh - - name: actionlint - run: | - set -euo pipefail - version="1.7.7" - tarball="actionlint_${version}_linux_amd64.tar.gz" - expected_sha256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" - curl -fsSL -o "$tarball" \ - "https://github.com/rhysd/actionlint/releases/download/v${version}/${tarball}" - echo "${expected_sha256} ${tarball}" | sha256sum -c - - tar -xzf "$tarball" - ./actionlint -color + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install bats + run: sudo apt-get update && sudo apt-get install -y bats + + - name: Run tests + run: make test diff --git a/.github/workflows/start-translation.yml b/.github/workflows/start-translation.yml index 8ed3558..c053650 100644 --- a/.github/workflows/start-translation.yml +++ b/.github/workflows/start-translation.yml @@ -113,159 +113,18 @@ jobs: source "$GITHUB_WORKSPACE/.github/workflows/assets/env.sh" # shellcheck source=assets/lib.sh source "$GITHUB_WORKSPACE/.github/workflows/assets/lib.sh" + # shellcheck source=assets/translation.sh + source "$GITHUB_WORKSPACE/.github/workflows/assets/translation.sh" # LIBS_REF: workflow env # shellcheck disable=SC2153 libs_ref="${LIBS_REF:?}" - # BOOST_ORG: env.sh - # shellcheck disable=SC2153 - boost_org="${BOOST_ORG:?}" ORG_WORK="$WORK_DIR/$MODULE_ORG" mkdir -p "$ORG_WORK" - # ── GitHub API helpers (via gh CLI) ────────────────────────────────── - - # Returns 0 if org/repo has an open PR into local-{lang_code} with head matching - # "translation-{lang_code}-*". - has_open_translation_pr() { - local org="$1" repo="$2" lang_code="$3" - local base_br="local-${lang_code}" - gh pr list --repo "$org/$repo" --state open --base "$base_br" --json headRefName \ - --jq ".[] | select(.headRefName | startswith(\"translation-${lang_code}-\")) | .headRefName" \ - 2>/dev/null | grep -q . - } - - # ── Organization repo sync helpers ────────────────────────────────────── - - # Wipe dest_repo (except .git), copy pruned source, commit, push master only. - sync_repo_master() { - local dest_repo="$1" sub_clone="$2" libs_ref="$3" - find "$dest_repo" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + || return 2 - cp -r "$sub_clone/." "$dest_repo/" || return 2 - set_git_bot_config "$dest_repo" - git -C "$dest_repo" add -A || return 2 - if ! git -C "$dest_repo" diff --cached --quiet; then - git -C "$dest_repo" commit -m "Update the original documentation of $libs_ref" || return 2 - fi - git -C "$dest_repo" push origin "$MASTER_BRANCH" || return 2 - } - - # Merge master into local-{lang_code} and push. - update_local_merge_from_master() { - local repo_dir="$1" lang_code="$2" - local local_br="local-${lang_code}" - git -C "$repo_dir" fetch origin "$MASTER_BRANCH" || return 2 - git -C "$repo_dir" fetch origin "$local_br" || return 2 - git -C "$repo_dir" checkout -B "$local_br" "origin/$local_br" || return 2 - git -C "$repo_dir" merge "origin/$MASTER_BRANCH" || return 2 - git -C "$repo_dir" push origin "${local_br}:${local_br}" || return 2 - } - - # Create local-{lang_code} in a library mirror repo from master, with create-tag.yml. - ensure_local_branch_in_repo() { - local dest_repo="$1" sub_name="$2" lang_code="$3" - local local_br="local-${lang_code}" - if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then - echo " Branch $local_br already exists in $sub_name." >&2 - return 0 - fi - echo " Creating branch $local_br in $sub_name from $MASTER_BRANCH..." >&2 - git -C "$dest_repo" fetch origin "$MASTER_BRANCH" || return 2 - git -C "$dest_repo" checkout -B "$local_br" "origin/$MASTER_BRANCH" || return 2 - add_create_tag_workflow "$dest_repo" || return 2 - git -C "$dest_repo" push -u origin "$local_br" || return 2 - echo " Created branch $local_br." >&2 - } - - # Handle local-{lang_code} branch in a library mirror repo after master is synced. - # Returns 0 if submodule should be added to add_or_update[lang_code]; 1 if skipped (open PR); 2 on git failure. - process_local_branch() { - local dest_repo="$1" sub_name="$2" lang_code="$3" - local local_br="local-${lang_code}" - if git -C "$dest_repo" ls-remote --exit-code --heads origin "$local_br" &>/dev/null; then - if has_open_translation_pr "$MODULE_ORG" "$sub_name" "$lang_code"; then - echo " Open translation PR found for $sub_name ($local_br), skipping." >&2 - return 1 - fi - update_local_merge_from_master "$dest_repo" "$lang_code" || return 2 - else - ensure_local_branch_in_repo "$dest_repo" "$sub_name" "$lang_code" || return 2 - fi - return 0 - } - - # ── Per-submodule processing ────────────────────────────────────────── - - process_one_submodule() { - local sub_name="$1" doc_paths - - if ! repo_exists "$MODULE_ORG" "$sub_name"; then - ORG_REPO_MISSING+=("$sub_name") - echo " Error: $MODULE_ORG/$sub_name does not exist. Run add-submodules first." >&2 - return 2 - fi - - doc_paths=$(get_doc_paths "$sub_name" "$libs_ref") || { - META_MISSING+=("$sub_name") - echo " No libraries.json." >&2; return 2 - } - [[ -z "$doc_paths" ]] && { - NO_DOC_PATHS+=("$sub_name") - echo " No doc paths in metadata, skipping." >&2; return 1 - } - - local sub_clone="$BOOST_WORK/$sub_name" - clone_repo "https://github.com/${boost_org}/${sub_name}.git" \ - "$libs_ref" "$sub_clone" || { echo " Clone failed." >&2; return 2; } - - local -a paths_arr - mapfile -t paths_arr <<< "$doc_paths" - prune_to_doc_only "$sub_clone" "${paths_arr[@]}" - - local org_repo_url="https://github.com/${MODULE_ORG}/${sub_name}.git" - local dest_repo="$ORG_WORK/$sub_name" - clone_repo "$org_repo_url" "$MASTER_BRANCH" "$dest_repo" keep || { - echo " clone_repo failed." >&2; return 2 - } - - sync_repo_master "$dest_repo" "$sub_clone" "$libs_ref" || return 2 - - local any_added=0 - for lang_code in "${lang_codes_arr[@]}"; do - if process_local_branch "$dest_repo" "$sub_name" "$lang_code"; then - if [[ -n "${add_or_update[$lang_code]:-}" ]]; then - add_or_update["$lang_code"]+=" $sub_name" - else - add_or_update["$lang_code"]="$sub_name" - fi - any_added=1 - else - rc=$? - [[ $rc -eq 2 ]] && return 2 - fi - done - [[ $any_added -eq 1 ]] - } - # ── Weblate helpers ─────────────────────────────────────────────────── - # Parse "[.adoc, .md]" or '[".adoc",".md"]' into one extension per line. - parse_extensions() { - local s="$1" - s="${s//[[:space:]]/}"; [[ -z "$s" ]] && return - s="${s#[}"; s="${s%]}" - local result=() - IFS=',' read -ra parts <<< "$s" - for part in "${parts[@]}"; do - part="${part//\"/}"; part="${part//\'/}" - [[ -z "$part" ]] && continue - [[ "$part" == .* ]] || part=".${part}" - result+=("$part") - done - printf '%s\n' "${result[@]}" - } - # POST to Weblate add-or-update (async: server returns 202 + task_id quickly). # Payload: {organization, add_or_update: {lang_code: [subs...]}, version, extensions} trigger_weblate() { diff --git a/.gitignore b/.gitignore index 59f888c..215caff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,7 @@ data/ # Local test scripts -test/ \ No newline at end of file +test/ + +# Tool caches (actionlint binary downloaded by scripts/lint.sh) +.cache/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c692c2 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: lint test check +lint: + scripts/lint.sh + +test: + scripts/test.sh + +check: lint test diff --git a/README.md b/README.md index e0c4014..4975e47 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,28 @@ documented below. | `LANG_CODES` | `add-submodules`, `start-translation` | Default language codes when **`client_payload.lang_codes`** is omitted (comma- or bracket-list, e.g. `zh_Hans,ja`). Must be set here or passed in the dispatch payload. | | `SUBMODULES_ORG` | `add-submodules`, `start-translation` | Optional. GitHub org for **`boostorg`** mirror repos (e.g. `CppDigest`). If unset, the org is the same as this repository’s owner. **`sync-translation`** relies on **`.gitmodules`** URLs already pointing at the correct hosts. | +## Development + +Install git hooks (runs lint + tests before each commit): + +```bash +scripts/install-git-hooks.sh +``` + +Requires **git** and **curl** for first-time setup. ShellCheck, actionlint, and bats are +downloaded automatically into `.cache/` when not already installed (`apt install bats` +is optional). + +Run checks manually: + +```bash +make lint # ShellCheck + actionlint +make test # bats test suite +make check # lint + test (same as pre-commit) +``` + +CI runs **`make test`** and **`scripts/lint.sh`** on every push and pull request. + ## License This repository is distributed under the diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..910fca0 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Point this repository at the version-controlled hooks in .githooks/. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +chmod +x .githooks/pre-commit scripts/lint.sh scripts/test.sh scripts/pre-commit.sh + +git config core.hooksPath .githooks +echo "Installed git hooks from .githooks/ (core.hooksPath=.githooks)." >&2 +echo "Pre-commit runs: scripts/pre-commit.sh (lint + make test)." >&2 diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..c6307ce --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Run ShellCheck and actionlint (same checks as CI lint job). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ensure_shellcheck() { + if command -v shellcheck >/dev/null 2>&1; then + SHELLCHECK_BIN="$(command -v shellcheck)" + return + fi + + local version="v0.11.0" + local cache_dir="$ROOT/.cache/shellcheck" + local bin="$cache_dir/shellcheck" + mkdir -p "$cache_dir" + + if [[ -x "$bin" ]]; then + SHELLCHECK_BIN="$bin" + return + fi + + local os arch tarball url expected_sha256 + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + case "$arch" in + x86_64) + tarball="shellcheck-${version}.linux.x86_64.tar.xz" + expected_sha256="8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" + ;; + aarch64|arm64) + tarball="shellcheck-${version}.linux.aarch64.tar.xz" + expected_sha256="12b331c1d2db6b9eb13cfca64306b1b157a86eb69db83023e261eaa7e7c14588" + ;; + *) + echo "lint: unsupported Linux architecture for shellcheck download: $arch" >&2 + echo "lint: install shellcheck manually (e.g. apt install shellcheck)." >&2 + exit 1 + ;; + esac + ;; + Darwin) + case "$arch" in + x86_64) + tarball="shellcheck-${version}.darwin.x86_64.tar.xz" + expected_sha256="3c89db4edcab7cf1c27bff178882e0f6f27f7afdf54e859fa041fca10febe4c6" + ;; + arm64) + tarball="shellcheck-${version}.darwin.aarch64.tar.xz" + expected_sha256="56affdd8de5527894dca6dc3d7e0a99a873b0f004d7aabc30ae407d3f48b0a79" + ;; + *) + echo "lint: unsupported macOS architecture for shellcheck download: $arch" >&2 + exit 1 + ;; + esac + ;; + *) + echo "lint: shellcheck not found and auto-download unsupported on $os." >&2 + echo "lint: install shellcheck manually (e.g. apt install shellcheck)." >&2 + exit 1 + ;; + esac + + url="https://github.com/koalaman/shellcheck/releases/download/${version}/${tarball}" + if [[ ! -f "$cache_dir/$tarball" ]]; then + echo "lint: downloading shellcheck ${version}..." >&2 + curl -fsSL -o "$cache_dir/$tarball" "$url" + fi + if [[ ! -d "$cache_dir/shellcheck-${version}" ]]; then + if [[ "$os" == "Linux" ]]; then + echo "${expected_sha256} $cache_dir/$tarball" | sha256sum -c - + else + echo "${expected_sha256} $cache_dir/$tarball" | shasum -a 256 -c - + fi + if ! tar -xJf "$cache_dir/$tarball" -C "$cache_dir"; then + echo "lint: failed to extract shellcheck (is xz installed? apt install xz-utils)." >&2 + exit 1 + fi + fi + cp "$cache_dir/shellcheck-${version}/shellcheck" "$bin" + chmod +x "$bin" + SHELLCHECK_BIN="$bin" +} + +ensure_shellcheck + +"$SHELLCHECK_BIN" -x \ + .github/workflows/assets/env.sh \ + .github/workflows/assets/lib.sh \ + .github/workflows/assets/translation.sh \ + scripts/*.sh \ + tests/helpers/*.bash + +ACTIONLINT_VERSION="1.7.7" +CACHE_DIR="$ROOT/.cache/actionlint" +ACTIONLINT_BIN="$CACHE_DIR/actionlint" +mkdir -p "$CACHE_DIR" + +if [[ ! -x "$ACTIONLINT_BIN" ]]; then + os="$(uname -s)" + arch="$(uname -m)" + case "$os" in + Linux) + case "$arch" in + x86_64) + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + expected_sha256="023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757" + ;; + aarch64|arm64) + tarball="actionlint_${ACTIONLINT_VERSION}_linux_arm64.tar.gz" + expected_sha256="401942f9c24ed71e4fe71b76c7d638f66d8633575c4016efd2977ce7c28317d0" + ;; + *) + echo "lint: unsupported Linux architecture for actionlint download: $arch" >&2 + exit 1 + ;; + esac + ;; + Darwin) + case "$arch" in + x86_64) + tarball="actionlint_${ACTIONLINT_VERSION}_darwin_amd64.tar.gz" + expected_sha256="28e5de5a05fc558474f638323d736d822fff183d2d492f0aecb2b73cc44584f5" + ;; + arm64) + tarball="actionlint_${ACTIONLINT_VERSION}_darwin_arm64.tar.gz" + expected_sha256="2693315b9093aeacb4ebd91a993fea54fc215057bf0da2659056b4bc033873db" + ;; + *) + echo "lint: unsupported macOS architecture for actionlint download: $arch" >&2 + exit 1 + ;; + esac + ;; + *) + echo "lint: unsupported OS for actionlint download: $os" >&2 + exit 1 + ;; + esac + curl -fsSL -o "$CACHE_DIR/$tarball" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${tarball}" + if [[ "$os" == "Linux" ]]; then + echo "${expected_sha256} $CACHE_DIR/$tarball" | sha256sum -c - + else + echo "${expected_sha256} $CACHE_DIR/$tarball" | shasum -a 256 -c - + fi + tar -xzf "$CACHE_DIR/$tarball" -C "$CACHE_DIR" +fi + +"$ACTIONLINT_BIN" -color diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100755 index 0000000..6ac355d --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Pre-commit checks: lint (ShellCheck + actionlint) and bats tests. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +echo "pre-commit: running lint..." >&2 +"$ROOT/scripts/lint.sh" + +echo "pre-commit: running tests..." >&2 +"$ROOT/scripts/test.sh" + +echo "pre-commit: all checks passed." >&2 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..acdcd68 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Run bats tests (downloads bats-core to .cache/ if bats is not installed). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ensure_bats() { + if command -v bats >/dev/null 2>&1; then + BATS_BIN="$(command -v bats)" + return + fi + + local bats_commit="b640ec3cf2c7c9cfc9e6351479261186f76eeec8" + local cache_dir="$ROOT/.cache/bats-core" + local bin="$cache_dir/bin/bats" + + if [[ -x "$bin" ]]; then + BATS_BIN="$bin" + return + fi + + if ! command -v git >/dev/null 2>&1; then + echo "test: bats is required (e.g. apt install bats) or install git to auto-download." >&2 + exit 1 + fi + + echo "test: downloading bats-core ${bats_commit}..." >&2 + rm -rf "$cache_dir" + git init -q "$cache_dir" + git -C "$cache_dir" remote add origin https://github.com/bats-core/bats-core.git + git -C "$cache_dir" fetch --depth 1 origin "$bats_commit" + git -C "$cache_dir" checkout -q FETCH_HEAD + BATS_BIN="$bin" +} + +ensure_bats +"$BATS_BIN" tests/ diff --git a/tests/helpers/common.bash b/tests/helpers/common.bash new file mode 100644 index 0000000..8d32cf1 --- /dev/null +++ b/tests/helpers/common.bash @@ -0,0 +1,61 @@ +# shellcheck shell=bash +# Shared helpers for bats tests. +# shellcheck disable=SC2034 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ASSETS_DIR="$REPO_ROOT/.github/workflows/assets" +FIXTURES_DIR="$REPO_ROOT/tests/helpers/fixtures" + +load_env() { + export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-testorg/boost-docs-translation}" + # shellcheck source=/dev/null + source "$ASSETS_DIR/env.sh" +} + +load_lib() { + load_env + # shellcheck source=/dev/null + source "$ASSETS_DIR/lib.sh" +} + +load_translation() { + load_lib + export GITHUB_WORKSPACE="$REPO_ROOT" + # shellcheck source=/dev/null + source "$ASSETS_DIR/translation.sh" +} + +# Run a function and capture its exit code (works under set -e in callers). +run_fn() { + local errexit_was_on=0 + [[ $- == *e* ]] && errexit_was_on=1 + set +e + "$@" + local rc=$? + if (( errexit_was_on )); then + set -e + else + set +e + fi + return "$rc" +} + +reset_process_globals() { + ORG_REPO_MISSING=() + META_MISSING=() + NO_DOC_PATHS=() + declare -gA add_or_update=() + lang_codes_arr=() + UPDATES=() +} + +init_process_globals() { + reset_process_globals + # Fixed values so tests behave the same locally and in CI (GITHUB_REPOSITORY varies). + MODULE_ORG="testorg" + MASTER_BRANCH="master" + BOOST_ORG="boostorg" + libs_ref="develop" + lang_codes_arr=("en") + add_or_update["en"]="" +} diff --git a/tests/helpers/fixtures/libraries-empty.json b/tests/helpers/fixtures/libraries-empty.json new file mode 100644 index 0000000..96c9c88 --- /dev/null +++ b/tests/helpers/fixtures/libraries-empty.json @@ -0,0 +1,10 @@ +[ + { + "name": "", + "key": "algorithm" + }, + { + "name": "NoKey", + "key": "" + } +] diff --git a/tests/helpers/fixtures/libraries-multi.json b/tests/helpers/fixtures/libraries-multi.json new file mode 100644 index 0000000..b2f6735 --- /dev/null +++ b/tests/helpers/fixtures/libraries-multi.json @@ -0,0 +1,10 @@ +[ + { + "name": "Minmax", + "key": "minmax" + }, + { + "name": "String", + "key": "string" + } +] diff --git a/tests/helpers/fixtures/libraries-single.json b/tests/helpers/fixtures/libraries-single.json new file mode 100644 index 0000000..01144dd --- /dev/null +++ b/tests/helpers/fixtures/libraries-single.json @@ -0,0 +1,6 @@ +[ + { + "name": "Algorithm", + "key": "algorithm" + } +] diff --git a/tests/helpers/git_fixtures.bash b/tests/helpers/git_fixtures.bash new file mode 100644 index 0000000..eb93e49 --- /dev/null +++ b/tests/helpers/git_fixtures.bash @@ -0,0 +1,57 @@ +# shellcheck shell=bash +# Temp git repos for integration-style tests. +# shellcheck disable=SC2034 + +init_git_fixture_root() { + GIT_FIXTURE_ROOT="$(mktemp -d)" +} + +cleanup_git_fixture_root() { + if [[ -n "${GIT_FIXTURE_ROOT:-}" && -d "$GIT_FIXTURE_ROOT" ]]; then + rm -rf "$GIT_FIXTURE_ROOT" + fi + GIT_FIXTURE_ROOT="" +} + +# Create a bare remote and a working clone with an initial commit on master. +# Sets BARE_REMOTE and WORK_REPO globals. +create_bare_remote_with_clone() { + local name="$1" + local bare="$GIT_FIXTURE_ROOT/${name}.git" + local work="$GIT_FIXTURE_ROOT/${name}" + + git init --bare "$bare" + git init "$work" + git -C "$work" checkout -b master + git -C "$work" config user.email "test@test.local" + git -C "$work" config user.name "Test" + git -C "$work" remote add origin "$bare" + echo "init" >"$work/README" + git -C "$work" add README + git -C "$work" commit -m "init" + git -C "$work" push -u origin master + + BARE_REMOTE="$bare" + WORK_REPO="$work" +} + +# Create a local branch on the bare remote (e.g. local-en). +create_remote_branch() { + local bare="$1" branch="$2" from_branch="${3:-master}" + local tmp + tmp="$(mktemp -d)" + git clone "$bare" "$tmp" + git -C "$tmp" checkout -b "$branch" "origin/$from_branch" + git -C "$tmp" push -u origin "$branch" + rm -rf "$tmp" +} + +# Populate a clone directory with doc-like content for prune tests. +create_prune_fixture_dir() { + local dir="$1" + mkdir -p "$dir/doc" "$dir/src" "$dir/.github/workflows" "$dir/minmax/other" + echo "doc" >"$dir/doc/readme.txt" + echo "src" >"$dir/src/main.cpp" + echo "wf" >"$dir/.github/workflows/ci.yml" + echo "other" >"$dir/minmax/other/data.txt" +} diff --git a/tests/helpers/mock_gh.bash b/tests/helpers/mock_gh.bash new file mode 100644 index 0000000..85ca83a --- /dev/null +++ b/tests/helpers/mock_gh.bash @@ -0,0 +1,70 @@ +# shellcheck shell=bash +# Install a stub gh on PATH for tests. + +MOCK_GH_DIR="" +_ORIG_PATH="" + +install_mock_gh() { + _ORIG_PATH="$PATH" + MOCK_GH_DIR="$(mktemp -d)" + cat >"$MOCK_GH_DIR/gh" <<'MOCK_EOF' +#!/usr/bin/env bash +set -uo pipefail + +cmd="${1:-}" +shift || true + +case "$cmd" in + repo) + if [[ "${1:-}" == "view" ]]; then + exit "${MOCK_REPO_VIEW_EXIT:-0}" + fi + ;; + api) + api_url="${*}" + if [[ "$api_url" == *libraries.json* ]]; then + if [[ -n "${MOCK_LIBRARIES_FIXTURE:-}" && -f "$MOCK_LIBRARIES_FIXTURE" ]]; then + cat "$MOCK_LIBRARIES_FIXTURE" + fi + exit "${MOCK_GH_API_EXIT:-0}" + fi + exit "${MOCK_GH_API_EXIT:-1}" + ;; + pr) + if [[ "${1:-}" == "list" ]]; then + if [[ -n "${MOCK_PR_LIST_STDERR:-}" ]]; then + printf '%s\n' "$MOCK_PR_LIST_STDERR" >&2 + fi + if [[ -n "${MOCK_PR_LIST_STDOUT:-}" ]]; then + printf '%s\n' "$MOCK_PR_LIST_STDOUT" + fi + exit "${MOCK_PR_LIST_EXIT:-0}" + fi + ;; +esac + +echo "mock gh: unhandled invocation: gh $*" >&2 +exit 127 +MOCK_EOF + chmod +x "$MOCK_GH_DIR/gh" + export PATH="$MOCK_GH_DIR:$PATH" +} + +restore_mock_gh() { + if [[ -n "$_ORIG_PATH" ]]; then + export PATH="$_ORIG_PATH" + fi + if [[ -n "$MOCK_GH_DIR" && -d "$MOCK_GH_DIR" ]]; then + rm -rf "$MOCK_GH_DIR" + fi + unset MOCK_GH_DIR _ORIG_PATH + unset MOCK_REPO_VIEW_EXIT MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT + unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT +} + +reset_mock_gh() { + export MOCK_REPO_VIEW_EXIT=0 + unset MOCK_LIBRARIES_FIXTURE MOCK_GH_API_EXIT + unset MOCK_PR_LIST_STDOUT MOCK_PR_LIST_STDERR MOCK_PR_LIST_EXIT + export MOCK_PR_LIST_EXIT=0 +} diff --git a/tests/test_lib.bats b/tests/test_lib.bats new file mode 100644 index 0000000..f0d7de4 --- /dev/null +++ b/tests/test_lib.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + load_lib +} + +@test "parse_list: empty input produces no output" { + run parse_list "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "parse_list: comma-separated codes" { + run parse_list "zh_Hans,en" + [ "$status" -eq 0 ] + [ "$output" = $'zh_Hans\nen' ] +} + +@test "parse_list: bracketed list with spaces" { + run parse_list "[zh_Hans, en]" + [ "$status" -eq 0 ] + [ "$output" = $'zh_Hans\nen' ] +} + +@test "parse_list: trailing comma ignored" { + run parse_list "en," + [ "$status" -eq 0 ] + [ "$output" = "en" ] +} + +@test "parse_extensions: bracketed extensions" { + run parse_extensions "[.adoc, .md]" + [ "$status" -eq 0 ] + [ "$output" = $'.adoc\n.md' ] +} + +@test "parse_extensions: JSON-style quoted extensions" { + run parse_extensions '[".adoc",".md"]' + [ "$status" -eq 0 ] + [ "$output" = $'.adoc\n.md' ] +} + +@test "parse_extensions: bare extension gets dot prefix" { + run parse_extensions "adoc" + [ "$status" -eq 0 ] + [ "$output" = ".adoc" ] +} + +@test "parse_extensions: empty input produces no output" { + run parse_extensions "" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "is_valid_lang_code: accepts common BCP 47 codes" { + is_valid_lang_code "en" + is_valid_lang_code "zh_Hans" + is_valid_lang_code "pt_BR" +} + +@test "is_valid_lang_code: rejects invalid codes" { + ! is_valid_lang_code "" + ! is_valid_lang_code "en US" + ! is_valid_lang_code "zh/Hans" + ! is_valid_lang_code "a" +} + +@test "get_doc_paths: single-library repo emits doc" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-single.json" + + run get_doc_paths "algorithm" "develop" + [ "$status" -eq 0 ] + [ "$output" = "doc" ] + + restore_mock_gh +} + +@test "get_doc_paths: multi-library repo emits per-key doc paths" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-multi.json" + + run get_doc_paths "container" "develop" + [ "$status" -eq 0 ] + [ "$output" = $'minmax/doc\nstring/doc' ] + + restore_mock_gh +} + +@test "get_doc_paths: API failure returns non-zero" { + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + install_mock_gh + export MOCK_GH_API_EXIT=1 + + run get_doc_paths "algorithm" "develop" + [ "$status" -eq 1 ] + + restore_mock_gh +} + +@test "prune_to_doc_only: keeps doc and root files, removes other dirs" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + init_git_fixture_root + local_dir="$GIT_FIXTURE_ROOT/prune-single" + create_prune_fixture_dir "$local_dir" + echo "root" >"$local_dir/LICENSE" + + prune_to_doc_only "$local_dir" "doc" + + [ -d "$local_dir/doc" ] + [ -f "$local_dir/LICENSE" ] + [ ! -d "$local_dir/src" ] + [ ! -d "$local_dir/.github" ] + + cleanup_git_fixture_root +} + +@test "prune_to_doc_only: multi-level path prunes intermediate dirs" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + init_git_fixture_root + local_dir="$GIT_FIXTURE_ROOT/prune-multi" + create_prune_fixture_dir "$local_dir" + + prune_to_doc_only "$local_dir" "minmax/doc" + + [ -d "$local_dir/minmax" ] + [ ! -d "$local_dir/minmax/other" ] + [ ! -d "$local_dir/doc" ] + [ ! -d "$local_dir/src" ] + + cleanup_git_fixture_root +} diff --git a/tests/test_merge_guard.bats b/tests/test_merge_guard.bats new file mode 100644 index 0000000..ada2cb0 --- /dev/null +++ b/tests/test_merge_guard.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + load_lib + install_mock_gh + reset_mock_gh +} + +teardown() { + restore_mock_gh +} + +@test "has_open_translation_pr: returns true when open PR exists" { + export MOCK_PR_LIST_STDOUT="translation-zh_Hans-foo" + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 0 ] +} + +@test "has_open_translation_pr: returns false when no open PR" { + unset MOCK_PR_LIST_STDOUT + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 1 ] +} + +@test "has_open_translation_pr: API failure returns 2 (fail-closed)" { + export MOCK_PR_LIST_EXIT=1 + export MOCK_PR_LIST_STDERR="API rate limit exceeded" + unset MOCK_PR_LIST_STDOUT + + run has_open_translation_pr "testorg" "algorithm" "zh_Hans" + [ "$status" -eq 2 ] + [[ "$output" == *"API rate limit exceeded"* ]] +} + +@test "process_local_branch: returns 1 when open translation PR exists" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + init_git_fixture_root + init_process_globals + + create_bare_remote_with_clone "mirror" + create_remote_branch "$BARE_REMOTE" "local-en" "master" + dest_repo="$GIT_FIXTURE_ROOT/mirror-clone" + git clone "$BARE_REMOTE" "$dest_repo" + + export MOCK_PR_LIST_STDOUT="translation-en-abc123" + + run process_local_branch "$dest_repo" "algorithm" "en" + [ "$status" -eq 1 ] + + cleanup_git_fixture_root +} + +@test "process_local_branch: returns 2 when PR check fails" { + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + init_git_fixture_root + init_process_globals + + create_bare_remote_with_clone "mirror" + create_remote_branch "$BARE_REMOTE" "local-en" "master" + dest_repo="$GIT_FIXTURE_ROOT/mirror-clone" + git clone "$BARE_REMOTE" "$dest_repo" + + export MOCK_PR_LIST_EXIT=1 + export MOCK_PR_LIST_STDERR="API rate limit exceeded" + unset MOCK_PR_LIST_STDOUT + + run process_local_branch "$dest_repo" "algorithm" "en" + [ "$status" -eq 2 ] + + cleanup_git_fixture_root +} diff --git a/tests/test_process_submodule.bats b/tests/test_process_submodule.bats new file mode 100644 index 0000000..fc9fb95 --- /dev/null +++ b/tests/test_process_submodule.bats @@ -0,0 +1,88 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=tests/helpers/common.bash + source "$BATS_TEST_DIRNAME/helpers/common.bash" + # shellcheck source=tests/helpers/mock_gh.bash + source "$BATS_TEST_DIRNAME/helpers/mock_gh.bash" + # shellcheck source=tests/helpers/git_fixtures.bash + source "$BATS_TEST_DIRNAME/helpers/git_fixtures.bash" + load_translation + install_mock_gh + reset_mock_gh + init_git_fixture_root + init_process_globals + + WORK_DIR="$(mktemp -d)" + BOOST_WORK="$WORK_DIR/boost" + ORG_WORK="$WORK_DIR/$MODULE_ORG" + mkdir -p "$BOOST_WORK" "$ORG_WORK" +} + +teardown() { + restore_mock_gh + cleanup_git_fixture_root + rm -rf "${WORK_DIR:-}" +} + +@test "process_one_submodule: returns 2 when mirror repo missing" { + export MOCK_REPO_VIEW_EXIT=1 + + set +e + process_one_submodule "missing-lib" + status=$? + set -e + + [ "$status" -eq 2 ] + [[ " ${ORG_REPO_MISSING[*]} " == *" missing-lib "* ]] +} + +@test "process_one_submodule: returns 1 when metadata has no doc paths" { + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-empty.json" + + set +e + process_one_submodule "algorithm" + status=$? + set -e + + [ "$status" -eq 1 ] + [[ " ${NO_DOC_PATHS[*]} " == *" algorithm "* ]] +} + +@test "process_one_submodule: returns 0 on success path" { + libs_ref="master" + export MOCK_LIBRARIES_FIXTURE="$FIXTURES_DIR/libraries-single.json" + + create_bare_remote_with_clone "boost-algorithm" + boost_bare="$BARE_REMOTE" + boost_work="$WORK_REPO" + mkdir -p "$boost_work/doc" + echo "doc content" >"$boost_work/doc/page.adoc" + git -C "$boost_work" add doc/ + git -C "$boost_work" commit -m "add doc" + git -C "$boost_work" push origin master + + create_bare_remote_with_clone "mirror-algorithm" + mirror_bare="$BARE_REMOTE" + + clone_repo() { + local url="$1" branch="$2" dest="$3" keep="${4:-}" + local bare="" + case "$url" in + *"${BOOST_ORG}/algorithm"*) bare="$boost_bare" ;; + *"${MODULE_ORG}/algorithm"*) bare="$mirror_bare" ;; + *) echo "unexpected clone url: $url (MODULE_ORG=$MODULE_ORG)" >&2; return 1 ;; + esac + mkdir -p "$dest" + git clone --branch "$branch" "$bare" "$dest" + [[ "$keep" == "keep" ]] || rm -rf "$dest/.git" + } + + set +e + process_one_submodule "algorithm" + status=$? + set -e + + [ "$status" -eq 0 ] + [[ "${add_or_update[en]}" == *algorithm* ]] +}