From b4519f3091655200dadf0b914a9531d1e0520ce5 Mon Sep 17 00:00:00 2001 From: Patrick Lewis <4015312+locus313@users.noreply.github.com> Date: Sat, 27 Jun 2026 09:40:42 -0700 Subject: [PATCH 1/4] feat: add bats unit tests for lib/github-common.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15 tests covering the shared library's pure-logic functions and gh_api sentinel returns — all testable without a real GitHub token. - validate_slug: valid (alphanumeric/hyphen/underscore) and invalid (space/slash/dot/metachar) inputs - require_env_var: unset, empty, and set variable cases - require_command: found and not-found cases - gh_api: __404__, __422__ sentinels and 200 body pass-through (curl is mocked per-test via a temporary PATH-prepended binary) Also adds a 'test' job to ci.yml that installs bats and runs the suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 15 +++++ tests/test_common.bats | 128 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 tests/test_common.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4422e83..04bc2ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,18 @@ jobs: --severity=warning \ --exclude=SC2034,SC1091 \ --shell=bash + + test: + name: Unit Tests + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install bats + run: sudo apt-get update -qq && sudo apt-get install -y bats + + - name: Run unit tests + run: bats tests/test_common.bats diff --git a/tests/test_common.bats b/tests/test_common.bats new file mode 100644 index 0000000..5e6c40a --- /dev/null +++ b/tests/test_common.bats @@ -0,0 +1,128 @@ +#!/usr/bin/env bats +# ============================================================================= +# tests/test_common.bats +# +# Unit tests for lib/github-common.sh — pure-logic functions and gh_api +# sentinel returns. No real network calls are made; curl is mocked per test. +# +# Requirements: +# - bats (https://github.com/bats-core/bats-core / apt install bats) +# +# Usage: +# bats tests/test_common.bats +# ============================================================================= + +LIB_PATH="${BATS_TEST_DIRNAME}/../lib/github-common.sh" + +# Per-test mock binary directory; prepend to PATH in subshells that need mocking +MOCK_BIN="" + +setup() { + MOCK_BIN="$(mktemp -d)" +} + +teardown() { + rm -rf "$MOCK_BIN" +} + +# Write a mock curl that outputs
\n — gh_api splits on the last line
+#
+# file mode (gh_api_paginate, validate_token)
+# curl ... -o [-D ]
+# Output: (body written to -o file; headers written to -D file)
+# =============================================================================
+
+HFILE=""
+BFILE=""
+
+# Parse only the flags we care about; everything else is ignored
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -D) HFILE="$2"; shift 2 ;;
+ -o) BFILE="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+done
+
+CODE="${MOCK_CURL_CODE:-200}"
+
+if [ -n "$BFILE" ]; then
+ # File mode: write body to -o target, write headers to -D target (if set)
+ printf '%s' "${MOCK_CURL_BODY:-}" > "$BFILE"
+ if [ -n "$HFILE" ]; then
+ printf 'HTTP/1.1 %s\r\n' "$CODE" > "$HFILE"
+ [ -n "${MOCK_CURL_LINK:-}" ] && \
+ printf 'link: <%s>; rel="next"\r\n' "${MOCK_CURL_LINK:-}" >> "$HFILE"
+ printf '\r\n' >> "$HFILE"
+ fi
+ printf '%s' "$CODE"
+else
+ # Stdout mode: body on first line(s), status code on last line
+ printf '%s\n%s' "${MOCK_CURL_BODY:-}" "$CODE"
+fi
diff --git a/tests/test_common.bats b/tests/test_common.bats
index 5e6c40a..11cdc3e 100644
--- a/tests/test_common.bats
+++ b/tests/test_common.bats
@@ -2,8 +2,9 @@
# =============================================================================
# tests/test_common.bats
#
-# Unit tests for lib/github-common.sh — pure-logic functions and gh_api
-# sentinel returns. No real network calls are made; curl is mocked per test.
+# Unit tests for lib/github-common.sh — pure-logic functions and API helpers.
+# No real network calls are made; curl and gh are mocked per test via a
+# temporary directory prepended to PATH.
#
# Requirements:
# - bats (https://github.com/bats-core/bats-core / apt install bats)
@@ -14,29 +15,45 @@
LIB_PATH="${BATS_TEST_DIRNAME}/../lib/github-common.sh"
-# Per-test mock binary directory; prepend to PATH in subshells that need mocking
MOCK_BIN=""
setup() {
MOCK_BIN="$(mktemp -d)"
+ # Default gh mock: fails all calls so GITHUB_TOKEN is not auto-resolved
+ printf '#!/bin/sh\nexit 1\n' > "$MOCK_BIN/gh"
+ chmod +x "$MOCK_BIN/gh"
}
teardown() {
rm -rf "$MOCK_BIN"
}
-# Write a mock curl that outputs \n and ignores all arguments.
-# gh_api captures: body=$(curl -s -w "\n%{http_code}" ...) then splits on last line.
-# Body and status are passed via env vars to avoid quoting issues with JSON content.
+# Install the universal curl mock. Passes response data via env vars so no
+# quoting issues arise with JSON or special characters in the body.
+# $1 HTTP status code (default: 200)
+# $2 Response body (default: empty)
+# $3 Link: next URL (default: empty = last page)
_mock_curl() {
- local code="$1" body="${2:-}"
+ local code="${1:-200}" body="${2:-}" link="${3:-}"
export MOCK_CURL_CODE="$code"
export MOCK_CURL_BODY="$body"
- printf '#!/bin/sh\nprintf "%%s\\n%%s" "$MOCK_CURL_BODY" "$MOCK_CURL_CODE"\n' \
- > "$MOCK_BIN/curl"
+ export MOCK_CURL_LINK="$link"
+ cp "${BATS_TEST_DIRNAME}/mock_curl.sh" "$MOCK_BIN/curl"
chmod +x "$MOCK_BIN/curl"
}
+# ─── err ──────────────────────────────────────────────────────────────────────
+
+@test "err: exits 1" {
+ run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; err 'something went wrong'"
+ [ "$status" -eq 1 ]
+}
+
+@test "err: output contains the supplied message" {
+ run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; err 'something went wrong'" 2>&1
+ [[ "$output" == *"something went wrong"* ]]
+}
+
# ─── validate_slug ────────────────────────────────────────────────────────────
@test "validate_slug: alphanumeric slug passes" {
@@ -103,6 +120,110 @@ _mock_curl() {
[ "$status" -eq 1 ]
}
+# ─── configure_gh_auth ────────────────────────────────────────────────────────
+
+@test "configure_gh_auth: passes and exports GH_TOKEN when GITHUB_TOKEN is set" {
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=mytoken
+ source '${LIB_PATH}' 2>/dev/null
+ configure_gh_auth
+ [ \"\$GH_TOKEN\" = 'mytoken' ]
+ "
+ [ "$status" -eq 0 ]
+}
+
+@test "configure_gh_auth: exits 1 when GITHUB_TOKEN unset and gh auth fails" {
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ unset GITHUB_TOKEN
+ source '${LIB_PATH}' 2>/dev/null
+ configure_gh_auth
+ "
+ [ "$status" -eq 1 ]
+}
+
+# ─── validate_token / validate_github_token ───────────────────────────────────
+
+@test "validate_token: exits 0 on HTTP 200" {
+ _mock_curl 200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ validate_token GITHUB_TOKEN
+ "
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_token: exits 1 on HTTP 401" {
+ _mock_curl 401
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ validate_token GITHUB_TOKEN
+ "
+ [ "$status" -eq 1 ]
+}
+
+@test "validate_github_token: emits warning for non-GitHub API_URL_PREFIX" {
+ _mock_curl 200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://not-github.example.com
+ source '${LIB_PATH}' 2>/dev/null
+ validate_github_token
+ "
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"does not look like"* ]]
+}
+
+@test "validate_github_token: no warning for api.github.com" {
+ _mock_curl 200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ validate_github_token
+ "
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"does not look like"* ]]
+}
+
+# ─── get_repo_page_count ──────────────────────────────────────────────────────
+
+@test "get_repo_page_count: returns 1 when there is no Link header" {
+ _mock_curl 200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ get_repo_page_count 'https://api.github.com/orgs/test/repos?per_page=100'
+ "
+ [ "$status" -eq 0 ]
+ [ "$output" = "1" ]
+}
+
+@test "get_repo_page_count: returns last page number from Link header" {
+ # Body contains a Link header line — get_repo_page_count greps stdout for &page=N
+ _mock_curl 200 'Link: ; rel="last"'
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ get_repo_page_count 'https://api.github.com/orgs/test/repos?per_page=100'
+ "
+ [ "$status" -eq 0 ]
+ [ "$output" = "7" ]
+}
+
# ─── gh_api sentinels ─────────────────────────────────────────────────────────
@test "gh_api: returns __404__ on HTTP 404" {
@@ -126,3 +247,58 @@ _mock_curl() {
[ "$status" -eq 0 ]
[ "$output" = '{"login":"test-org"}' ]
}
+
+@test "gh_api: prepends API_URL_PREFIX when path starts with /" {
+ _mock_curl 200 '{"ok":true}'
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ result=\$(gh_api '/some/path')
+ [ \"\$result\" = '{\"ok\":true}' ]
+ "
+ [ "$status" -eq 0 ]
+}
+
+# ─── gh_api_paginate ──────────────────────────────────────────────────────────
+
+@test "gh_api_paginate: exits 0 silently on HTTP 404" {
+ _mock_curl 404
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ gh_api_paginate '/orgs/nonexistent/repos'
+ "
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "gh_api_paginate: exits 0 silently on HTTP 422" {
+ _mock_curl 422
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ gh_api_paginate '/orgs/test/repos'
+ "
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "gh_api_paginate: outputs items from a single page" {
+ _mock_curl 200 '[{"name":"repo-a"},{"name":"repo-b"}]'
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export API_URL_PREFIX=https://api.github.com
+ source '${LIB_PATH}' 2>/dev/null
+ gh_api_paginate '/orgs/test/repos'
+ "
+ [ "$status" -eq 0 ]
+ [[ "$output" == *'"name":"repo-a"'* ]]
+ [[ "$output" == *'"name":"repo-b"'* ]]
+}
diff --git a/tests/test_script_validation.bats b/tests/test_script_validation.bats
new file mode 100644
index 0000000..8ef7d68
--- /dev/null
+++ b/tests/test_script_validation.bats
@@ -0,0 +1,475 @@
+#!/usr/bin/env bats
+# =============================================================================
+# tests/test_script_validation.bats
+#
+# Per-script tests verifying:
+# - Required environment variables: missing var exits 1 before any API call
+# - CLI argument parsing: unknown args exit 1, --help exits 0, recognised
+# flags (--dry-run, --type) do not trigger "Unknown argument" errors
+# - Script-specific input validation: invalid enum values, invalid URL
+# allowlists, missing required positional args
+#
+# curl and gh are mocked in MOCK_BIN so no real network calls are made.
+# Where a test needs to reach past the token-validation step, _mock_curl_200
+# installs a mock that returns HTTP 200.
+#
+# Requirements:
+# - bats (https://github.com/bats-core/bats-core / apt install bats)
+#
+# Usage:
+# bats tests/test_script_validation.bats
+# =============================================================================
+
+REPO_ROOT="${BATS_TEST_DIRNAME}/.."
+MOCK_BIN=""
+
+setup() {
+ MOCK_BIN="$(mktemp -d)"
+ # gh mock: fail all calls so GITHUB_TOKEN is never auto-resolved from a session
+ printf '#!/bin/sh\nexit 1\n' > "$MOCK_BIN/gh"
+ chmod +x "$MOCK_BIN/gh"
+}
+
+teardown() {
+ rm -rf "$MOCK_BIN"
+}
+
+# Install a mock curl that always returns HTTP 200 with an empty body.
+# Used for tests that need to pass token validation before reaching later checks.
+_mock_curl_200() {
+ export MOCK_CURL_CODE=200
+ export MOCK_CURL_BODY=""
+ export MOCK_CURL_LINK=""
+ cp "${BATS_TEST_DIRNAME}/mock_curl.sh" "$MOCK_BIN/curl"
+ chmod +x "$MOCK_BIN/curl"
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-add-repo-collaborators-by-pattern
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-add-repo-collaborators-by-pattern: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-add-repo-collaborators-by-pattern/github-add-repo-collaborators-by-pattern.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-repo-collaborators-by-pattern: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-add-repo-collaborators-by-pattern/github-add-repo-collaborators-by-pattern.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-repo-collaborators-by-pattern: exits 1 when COLLABORATORS is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; unset COLLABORATORS; bash '${REPO_ROOT}/org-admin/github-add-repo-collaborators-by-pattern/github-add-repo-collaborators-by-pattern.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-repo-collaborators-by-pattern: exits 1 when REPO_NAME_REGEX is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; export COLLABORATORS=user1; unset REPO_NAME_REGEX; bash '${REPO_ROOT}/org-admin/github-add-repo-collaborators-by-pattern/github-add-repo-collaborators-by-pattern.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-add-repo-permissions
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-add-repo-permissions: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-repo-permissions: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-archive-old-repos
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-archive-old-repos: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-archive-old-repos/github-archive-old-repos.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-archive-old-repos: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-archive-old-repos/github-archive-old-repos.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-auto-repo-creation
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-auto-repo-creation: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-auto-repo-creation/github-auto-repo-creation.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-auto-repo-creation: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-auto-repo-creation/github-auto-repo-creation.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-auto-repo-creation: exits 1 when REPO_NAMES is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; unset REPO_NAMES; bash '${REPO_ROOT}/org-admin/github-auto-repo-creation/github-auto-repo-creation.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-auto-repo-creation: exits 1 when REPO_OWNERS is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; export REPO_NAMES=my-repo; unset REPO_OWNERS; bash '${REPO_ROOT}/org-admin/github-auto-repo-creation/github-auto-repo-creation.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-close-archived-repo-security-alerts
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-close-archived-repo-security-alerts: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-close-archived-repo-security-alerts: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-close-archived-repo-security-alerts: exits 1 for unknown argument" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh' --garbage"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Unknown argument"* ]]
+}
+
+@test "github-close-archived-repo-security-alerts: exits 1 for invalid --type value" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh' --type badtype"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid --type"* ]]
+}
+
+@test "github-close-archived-repo-security-alerts: --dry-run is recognised (fails at token check)" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh' --dry-run"
+ [ "$status" -eq 1 ]
+ [[ "$output" != *"Unknown argument"* ]]
+}
+
+@test "github-close-archived-repo-security-alerts: exits 1 for invalid DEPENDABOT_REASON" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export DEPENDABOT_REASON=bad-value; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh'"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid DEPENDABOT_REASON"* ]]
+}
+
+@test "github-close-archived-repo-security-alerts: exits 1 for invalid SECRET_SCANNING_RESOLUTION" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export SECRET_SCANNING_RESOLUTION=bad-value; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-close-archived-repo-security-alerts/github-close-archived-repo-security-alerts.sh'"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Invalid SECRET_SCANNING_RESOLUTION"* ]]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-enable-issues
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-enable-issues: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-enable-issues/github-enable-issues.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-enable-issues: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-enable-issues/github-enable-issues.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-enable-issues: --help exits 0" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; bash '${REPO_ROOT}/org-admin/github-enable-issues/github-enable-issues.sh' --help"
+ [ "$status" -eq 0 ]
+}
+
+@test "github-enable-issues: exits 1 for unknown argument" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-enable-issues/github-enable-issues.sh' --garbage"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Unknown argument"* ]]
+}
+
+@test "github-enable-issues: --dry-run is recognised (fails at token check)" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-enable-issues/github-enable-issues.sh' --dry-run"
+ [ "$status" -eq 1 ]
+ [[ "$output" != *"Unknown argument"* ]]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-get-repo-list
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-get-repo-list: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-get-repo-list/github-get-repo-list.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-get-repo-list: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-get-repo-list/github-get-repo-list.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-import-repo
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-import-repo: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-import-repo/github-import-repo.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-import-repo: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-import-repo/github-import-repo.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-import-repo: exits 1 for non-GitHub GIT_URL_PREFIX" {
+ # Needs to pass past require_env_var and validate_github_token before hitting the allowlist check
+ _mock_curl_200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export ORG=test-org
+ export OWNER_USERNAME=user
+ export GIT_URL_PREFIX=https://evil.example.com
+ export API_URL_PREFIX=https://api.github.com
+ bash '${REPO_ROOT}/org-admin/github-import-repo/github-import-repo.sh' src dest
+ "
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"not a recognised GitHub host"* ]]
+}
+
+@test "github-import-repo: accepts default GIT_URL_PREFIX (github.com)" {
+ # Verify the allowlist itself passes for the default value — reaches git, not the guard
+ _mock_curl_200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export ORG=test-org
+ export OWNER_USERNAME=user
+ export GIT_URL_PREFIX=https://github.com
+ export API_URL_PREFIX=https://api.github.com
+ bash '${REPO_ROOT}/org-admin/github-import-repo/github-import-repo.sh' src dest 2>&1 || true
+ "
+ [[ "$output" != *"not a recognised GitHub host"* ]]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-migrate-internal-repos-to-private
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-migrate-internal-repos-to-private: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-migrate-internal-repos-to-private/github-migrate-internal-repos-to-private.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-migrate-internal-repos-to-private: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-migrate-internal-repos-to-private/github-migrate-internal-repos-to-private.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# org-admin/github-repo-from-template
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-repo-from-template: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/org-admin/github-repo-from-template/github-repo-from-template.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-repo-from-template: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/org-admin/github-repo-from-template/github-repo-from-template.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-repo-from-template: exits 1 when TEMPLATE_REPO is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; unset TEMPLATE_REPO; bash '${REPO_ROOT}/org-admin/github-repo-from-template/github-repo-from-template.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-repo-from-template: exits 1 when CD_USERNAME is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; export TEMPLATE_REPO=my-template; unset CD_USERNAME; bash '${REPO_ROOT}/org-admin/github-repo-from-template/github-repo-from-template.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-repo-from-template: exits 1 when CD_GITHUB_TOKEN is not set" {
+ # CD_GITHUB_TOKEN is checked after validate_github_token — needs mocked curl
+ _mock_curl_200
+ run bash -c "
+ export PATH='${MOCK_BIN}:${PATH}'
+ export GITHUB_TOKEN=fake
+ export ORG=test
+ export TEMPLATE_REPO=my-template
+ export CD_USERNAME=cduser
+ export REPO_NAME=new-repo
+ unset CD_GITHUB_TOKEN
+ export API_URL_PREFIX=https://api.github.com
+ bash '${REPO_ROOT}/org-admin/github-repo-from-template/github-repo-from-template.sh'
+ "
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# enterprise/github-add-enterprise-team-read-permissions
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-add-enterprise-team-read-permissions: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/enterprise/github-add-enterprise-team-read-permissions/github-add-enterprise-team-read-permissions.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-enterprise-team-read-permissions: exits 1 when ENTERPRISE is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ENTERPRISE; bash '${REPO_ROOT}/enterprise/github-add-enterprise-team-read-permissions/github-add-enterprise-team-read-permissions.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-add-enterprise-team-read-permissions: exits 1 when ENTERPRISE_TEAM_SLUG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ENTERPRISE=my-ent; unset ENTERPRISE_TEAM_SLUG; bash '${REPO_ROOT}/enterprise/github-add-enterprise-team-read-permissions/github-add-enterprise-team-read-permissions.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# enterprise/github-dockerfile-discovery
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-dockerfile-discovery: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/enterprise/github-dockerfile-discovery/github-dockerfile-discovery.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# enterprise/github-get-consumed-licenses
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-get-consumed-licenses: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/enterprise/github-get-consumed-licenses/github-get-consumed-licenses.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-get-consumed-licenses: exits 1 when ENTERPRISE is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ENTERPRISE; bash '${REPO_ROOT}/enterprise/github-get-consumed-licenses/github-get-consumed-licenses.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# enterprise/github-get-public-repos
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-get-public-repos: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/enterprise/github-get-public-repos/github-get-public-repos.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# enterprise/github-install-enterprise-app
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-install-enterprise-app: exits 1 when ENTERPRISE is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset ENTERPRISE; bash '${REPO_ROOT}/enterprise/github-install-enterprise-app/github-install-enterprise-app.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-install-enterprise-app: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export ENTERPRISE=my-ent; unset ORG; bash '${REPO_ROOT}/enterprise/github-install-enterprise-app/github-install-enterprise-app.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-install-enterprise-app: exits 1 when INSTALLER_APP_CLIENT_ID is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export ENTERPRISE=my-ent; export ORG=test; unset INSTALLER_APP_CLIENT_ID; bash '${REPO_ROOT}/enterprise/github-install-enterprise-app/github-install-enterprise-app.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-install-enterprise-app: exits 1 for unknown argument" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset ENTERPRISE; bash '${REPO_ROOT}/enterprise/github-install-enterprise-app/github-install-enterprise-app.sh' --garbage"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Unknown option"* ]]
+}
+
+@test "github-install-enterprise-app: --help exits 0" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; bash '${REPO_ROOT}/enterprise/github-install-enterprise-app/github-install-enterprise-app.sh' --help"
+ [ "$status" -eq 0 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# reporting/github-monthly-issues-report
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-monthly-issues-report: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/reporting/github-monthly-issues-report/github-monthly-issues-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-monthly-issues-report: exits 1 when ORG is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; unset ORG; bash '${REPO_ROOT}/reporting/github-monthly-issues-report/github-monthly-issues-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-monthly-issues-report: exits 1 when REPO is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; unset REPO; bash '${REPO_ROOT}/reporting/github-monthly-issues-report/github-monthly-issues-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-monthly-issues-report: exits 1 when MONTH_START is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; export REPO=my-repo; unset MONTH_START; bash '${REPO_ROOT}/reporting/github-monthly-issues-report/github-monthly-issues-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-monthly-issues-report: exits 1 when MONTH_END is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; export ORG=test; export REPO=my-repo; export MONTH_START=2024-01-01; unset MONTH_END; bash '${REPO_ROOT}/reporting/github-monthly-issues-report/github-monthly-issues-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# reporting/github-repo-permissions-report
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-repo-permissions-report: exits 1 when -r is not provided" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; export GITHUB_TOKEN=fake; bash '${REPO_ROOT}/reporting/github-repo-permissions-report/github-repo-permissions-report.sh'"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Repository is required"* ]]
+}
+
+@test "github-repo-permissions-report: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/reporting/github-repo-permissions-report/github-repo-permissions-report.sh' -r org/repo"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-repo-permissions-report: --help exits 0" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; bash '${REPO_ROOT}/reporting/github-repo-permissions-report/github-repo-permissions-report.sh' --help"
+ [ "$status" -eq 0 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# reporting/github-copilot-report
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-copilot-report: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/reporting/github-copilot-report/github-copilot-report.sh'"
+ [ "$status" -eq 1 ]
+}
+
+@test "github-copilot-report: --help exits 0" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; bash '${REPO_ROOT}/reporting/github-copilot-report/github-copilot-report.sh' --help"
+ [ "$status" -eq 0 ]
+}
+
+# ═══════════════════════════════════════════════════════════════════════════════
+# personal/github-organize-stars
+# ═══════════════════════════════════════════════════════════════════════════════
+
+@test "github-organize-stars: exits 1 when not authenticated (no token, no gh session)" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/personal/github-organize-stars/github-organize-stars.sh'"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"Not authenticated"* ]]
+}
+
+@test "github-organize-stars: --help exits 0" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; bash '${REPO_ROOT}/personal/github-organize-stars/github-organize-stars.sh' --help"
+ [ "$status" -eq 0 ]
+}
+
+@test "github-organize-stars: --dry-run is recognised (fails at auth check, not arg parse)" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/personal/github-organize-stars/github-organize-stars.sh' --dry-run"
+ [ "$status" -eq 1 ]
+ [[ "$output" != *"Unknown option"* ]]
+}
From 9e34f1ad7c1c195eef705126caa9a3f01f224619 Mon Sep 17 00:00:00 2001
From: Patrick Lewis <4015312+locus313@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:00:58 -0700
Subject: [PATCH 3/4] docs: mandate bats tests for all new scripts in AGENTS.md
and copilot-instructions.md
- Replaced the placeholder Testing section in AGENTS.md with full
bats suite documentation: test files table, what to test per script,
and the mock_curl.sh pattern with a code example
- Added step 7 (write tests) to the Adding a New Script checklist in
AGENTS.md; renumbered subsequent steps
- Updated CI/CD bullet to mention the bats test job
- Updated Maintenance Matrix 'Add a new script' row to include
tests/test_script_validation.bats
- Mirrored all changes in .github/copilot-instructions.md: expanded
Adding New Scripts checklist with mandatory test step, replaced
single-line Testing Approach with table + requirement prose
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/copilot-instructions.md | 24 ++++++++++--
AGENTS.md | 65 ++++++++++++++++++++++++++++-----
2 files changed, 76 insertions(+), 13 deletions(-)
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 02aa54c..7f61687 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -227,7 +227,8 @@ Uses API version `2026-03-10` and the new usage-metrics NDJSON endpoints (signed
3. Source `lib/github-common.sh` for validation and output helpers
4. Start with the standard boilerplate (see Script Anatomy above)
5. Create `action.yml` in the same directory — expose every env var as a named input (required inputs without defaults, optional inputs with sensible defaults); map CLI flags such as `--dry-run` and `--type` to boolean/string inputs and build an `ARGS` array in the `run:` step. Mirror the pattern of any existing `action.yml`.
-6. Document in README.md following existing format:
+6. **Add tests to `tests/test_script_validation.bats`** — mandatory. Add a labelled section with tests for every required env var missing (exit 1), unknown CLI args (exit 1), `--help` exits 0, and script-specific validation guards. See existing sections for the pattern.
+7. Document in README.md following existing format:
- Use case description
- Required variables table
- Usage example with exports
@@ -235,7 +236,24 @@ Uses API version `2026-03-10` and the new usage-metrics NDJSON endpoints (signed
- Add a row to the Available Actions table in the "Using Scripts in GitHub Actions" section
### Testing Approach
-- **Always test on a test organization first**
+
+The project has a bats unit-test suite in `tests/`. Run the full suite with:
+
+```bash
+bats tests/
+```
+
+| File | What it covers |
+|------|----------------|
+| `tests/test_common.bats` | `lib/github-common.sh` pure-logic functions and API helpers |
+| `tests/test_script_validation.bats` | Every script — missing env vars, invalid args, `--help`, script-specific guards |
+| `tests/mock_curl.sh` | Universal curl mock used by both test files |
+
+Every new script **must** include a test section in `tests/test_script_validation.bats` before it is merged. At minimum test: each required env var missing exits 1, unknown CLI args exit 1, and any enum or allowlist validation specific to the script.
+
+Additional testing approaches:
+- **Always test on a test organization first** before running against production
+- **Dry-run flags** — several scripts support `--dry-run` to preview changes without applying them
### Commit Messages — Conventional Commits (required)
@@ -285,7 +303,7 @@ When you change one of these files, you must also update the files in the "Also
| `README.md` — script documentation | Verify the script's `# ===` header comment still matches (env vars, options, requirements) |
| `.githooks/pre-commit` | `install-hooks.sh` if hook path or installation instructions change; README.md Best Practices section |
| `install-hooks.sh` | README.md Installation section |
-| Add a new script | `action.yml` in the same directory; `README.md` (add use case, env var table, usage example, Available Actions table row) |
+| Add a new script | `tests/test_script_validation.bats` (add a test section for the new script); `action.yml` in the same directory; `README.md` (add use case, env var table, usage example, Available Actions table row) |
| Add a new domain folder | `README.md` top-level structure description; `AGENTS.md` Repository Structure section |
| `.github/workflows/ci.yml` — shellcheck flags | `.githooks/pre-commit` shellcheck invocation (keep them in sync) |
| `.github/workflows/copilot-setup-steps.yml` — tool versions | `AGENTS.md` Tech Stack table |
diff --git a/AGENTS.md b/AGENTS.md
index 6a70967..57e633c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -79,14 +79,58 @@ find . -name "*.sh" | xargs shellcheck --severity=warning --exclude=SC2034,SC109
## Testing
-There is no automated test suite. The validation approach is:
+The project has a bats unit-test suite in `tests/`. **Every new script must ship with tests.**
-1. **Pre-commit hook** — shellcheck on every staged `.sh` file; gitleaks secret scan
-2. **Dry-run flags** — several scripts support `--dry-run` to preview changes without applying them:
- - `github-close-archived-repo-security-alerts`
- - `github-enable-issues`
- - `github-organize-stars`
-3. **Test org first** — always run against a non-production GitHub org before production
+```bash
+# Run all tests
+bats tests/
+
+# Run a single file
+bats tests/test_common.bats
+```
+
+### Test files
+
+| File | What it covers |
+|------|----------------|
+| `tests/test_common.bats` | `lib/github-common.sh` — pure-logic functions (`validate_slug`, `require_env_var`, `require_command`, `err`, `configure_gh_auth`, `validate_token`, `get_repo_page_count`) and API helpers (`gh_api` sentinels, `gh_api_paginate`) |
+| `tests/test_script_validation.bats` | Every script — missing required env vars exit 1, invalid CLI args exit 1, `--help` exits 0, script-specific enum/allowlist validation |
+| `tests/mock_curl.sh` | Universal drop-in curl mock (used by both test files); response data via env vars `MOCK_CURL_CODE`, `MOCK_CURL_BODY`, `MOCK_CURL_LINK` |
+
+### What to test for every new script
+
+1. **Missing required env vars** — one `@test` per required variable, in the order the script checks them. Each test asserts `status -eq 1`.
+2. **Invalid CLI args** — unknown flag exits 1 with an "Unknown" message.
+3. **`--help` flag** — exits 0 (for scripts that implement it).
+4. **Recognised flags** — `--dry-run` and other known flags do not trigger the unknown-arg error (test by asserting output does *not* contain "Unknown").
+5. **Script-specific guards** — enum validation (`--type`, `DEPENDABOT_REASON`), URL allowlists (`GIT_URL_PREFIX`), required positional args.
+
+### Mocking pattern
+
+Tests shadow real binaries by prepending a `MOCK_BIN` directory to `PATH`:
+
+```bash
+setup() {
+ MOCK_BIN="$(mktemp -d)"
+ # Fail gh auth so GITHUB_TOKEN is never auto-resolved from a session
+ printf '#!/bin/sh\nexit 1\n' > "$MOCK_BIN/gh"
+ chmod +x "$MOCK_BIN/gh"
+}
+
+teardown() { rm -rf "$MOCK_BIN"; }
+
+@test "my-script: exits 1 when GITHUB_TOKEN is not set" {
+ run bash -c "export PATH='${MOCK_BIN}:${PATH}'; unset GITHUB_TOKEN; bash '${REPO_ROOT}/domain/my-script/my-script.sh'"
+ [ "$status" -eq 1 ]
+}
+```
+
+Use `_mock_curl_200` (defined in `test_script_validation.bats`) when a test must reach code that runs after `validate_github_token`.
+
+### Dry-run flags and other testing approaches
+
+- **`--dry-run`** — several scripts support it to preview changes without applying them.
+- **Test org first** — always run against a non-production GitHub org before production.
---
@@ -185,8 +229,9 @@ done
4. **Source the shared library** using `SCRIPT_DIR`
5. **Validate all inputs** before any API calls
6. **Create `action.yml`** in the same directory — expose every env var as an input (required inputs first, optional inputs with defaults); map CLI flags (`--dry-run`, `--type`, etc.) to boolean/string inputs and construct the `ARGS` array in the `run:` step. See existing `action.yml` files for the pattern.
-7. **Add to README.md** — follow the existing format: use case, env var table, usage example, output format; add a row to the Available Actions table in the "Using Scripts in GitHub Actions" section
-8. Place in the correct domain:
+7. **Add tests to `tests/test_script_validation.bats`** — add a labelled section (`# ═══ github- ═══`) with tests for: every required env var missing (exit 1), unknown CLI args (exit 1), `--help` exits 0, and any script-specific validation (enum guards, URL allowlists, positional args). See existing sections for the pattern.
+8. **Add to README.md** — follow the existing format: use case, env var table, usage example, output format; add a row to the Available Actions table in the "Using Scripts in GitHub Actions" section
+9. Place in the correct domain:
- `org-admin/` — organization-level operations (repos, teams, members)
- `enterprise/` — enterprise-level operations (licenses, org enumeration)
- `reporting/` — read-only reports and audits
@@ -199,7 +244,7 @@ done
- **Pre-commit hook:** `.githooks/pre-commit` — runs gitleaks + shellcheck on staged `.sh` files
- **Install:** `./install-hooks.sh` or `git config core.hooksPath .githooks`
- **Bypass (emergency only):** `git commit --no-verify`
-- **CI:** shellcheck runs on all `.sh` files on every PR (`.github/workflows/ci.yml`)
+- **CI:** shellcheck runs on all `.sh` files on every PR (`.github/workflows/ci.yml`); bats unit tests run in a dedicated `test` job (`bats tests/`)
- **Releases:** automated by Release Please (`.github/workflows/release.yml`) — pushes to `main` trigger a release PR; merging it publishes the GitHub Release and tag
## Commit Messages — Conventional Commits (required)
From 454ef3d5cf236d447242578158aefa0fb69363bc Mon Sep 17 00:00:00 2001
From: Patrick Lewis <4015312+locus313@users.noreply.github.com>
Date: Sat, 27 Jun 2026 15:03:28 -0700
Subject: [PATCH 4/4] docs: add bats test suite documentation to README.md
- Add Unit Tests subsection after Pre-commit Hooks: install
instructions for macOS/Ubuntu, bats tests/ run command, test-file
table, note that CI runs bats on every PR
- Update Contributing checklist: add 'add test section to
test_script_validation.bats' to step 3, add step 6 'bats tests/'
before the test-on-non-prod-org step
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
README.md | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f4e6d8f..de2b143 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,28 @@ brew install gitleaks shellcheck
> [!TIP]
> To bypass the hooks in an emergency: `git commit --no-verify`. Use sparingly — the hooks exist to prevent secrets from reaching the remote.
+### Unit Tests
+
+The repository includes a [bats](https://github.com/bats-core/bats-core) unit-test suite in `tests/`. Install bats and run the full suite:
+
+```bash
+# macOS
+brew install bats-core
+
+# Ubuntu / Debian
+sudo apt-get install -y bats
+
+# Run all tests
+bats tests/
+```
+
+| File | What it covers |
+|------|----------------|
+| `tests/test_common.bats` | `lib/github-common.sh` functions (`validate_slug`, `require_env_var`, `gh_api` sentinels, `gh_api_paginate`, etc.) |
+| `tests/test_script_validation.bats` | Every script — missing required env vars, invalid CLI args, `--help`, and script-specific guards |
+
+CI runs `bats tests/` on every pull request alongside shellcheck.
+
## Scripts
Each script is a self-contained utility designed for a specific task. Navigate to the script's directory, set the required environment variables, and execute.
@@ -1159,8 +1181,10 @@ Contributions are welcome! Please follow these steps:
- Start with the `# ===` header and `set -euo pipefail`
- Source `lib/github-common.sh` and validate all inputs
- Create `action.yml` in the same directory (see existing actions for the pattern)
+ - Add a test section for the new script in `tests/test_script_validation.bats`
4. **Update README.md** with the env var table, usage example, and a row in the Available Actions table
5. **Run shellcheck:** `shellcheck --severity=warning --exclude=SC2034,SC1091 --shell=bash your-script.sh`
-6. **Test on a non-production org** before submitting
+6. **Run the test suite:** `bats tests/`
+7. **Test on a non-production org** before submitting
7. **Commit using [Conventional Commits](https://www.conventionalcommits.org/)** — `CHANGELOG.md` is auto-generated from commit messages; do not edit it manually
8. **Open a PR** — the PR template will guide you through the checklist