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 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. +_mock_curl() { + local code="$1" body="${2:-}" + 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" + chmod +x "$MOCK_BIN/curl" +} + +# ─── validate_slug ──────────────────────────────────────────────────────────── + +@test "validate_slug: alphanumeric slug passes" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'myrepo123' 'repo'" + [ "$status" -eq 0 ] +} + +@test "validate_slug: slug with hyphens passes" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'my-org-repo' 'repo'" + [ "$status" -eq 0 ] +} + +@test "validate_slug: slug with underscores passes" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'my_repo_name' 'repo'" + [ "$status" -eq 0 ] +} + +@test "validate_slug: space in slug exits 1" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'my repo' 'repo'" + [ "$status" -eq 1 ] +} + +@test "validate_slug: slash in slug exits 1" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'org/repo' 'repo'" + [ "$status" -eq 1 ] +} + +@test "validate_slug: dot in slug exits 1" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'my.repo' 'repo'" + [ "$status" -eq 1 ] +} + +@test "validate_slug: shell metachar in slug exits 1" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; validate_slug 'repo\$(evil)' 'repo'" + [ "$status" -eq 1 ] +} + +# ─── require_env_var ────────────────────────────────────────────────────────── + +@test "require_env_var: exits 1 when variable is unset" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; unset MY_VAR; require_env_var MY_VAR" + [ "$status" -eq 1 ] +} + +@test "require_env_var: exits 1 when variable is empty string" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; MY_VAR=''; require_env_var MY_VAR" + [ "$status" -eq 1 ] +} + +@test "require_env_var: exits 0 when variable has a value" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; MY_VAR=hello; require_env_var MY_VAR" + [ "$status" -eq 0 ] +} + +# ─── require_command ────────────────────────────────────────────────────────── + +@test "require_command: exits 0 for an existing command" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; require_command bash" + [ "$status" -eq 0 ] +} + +@test "require_command: exits 1 for a command that does not exist" { + run bash -c "GITHUB_TOKEN=x source '${LIB_PATH}' 2>/dev/null; require_command __no_such_cmd__" + [ "$status" -eq 1 ] +} + +# ─── gh_api sentinels ───────────────────────────────────────────────────────── + +@test "gh_api: returns __404__ on HTTP 404" { + _mock_curl 404 + # Expand ${MOCK_BIN} and ${PATH} now so the subshell inherits full system PATH + 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 '/orgs/nonexistent'" + [ "$status" -eq 0 ] + [ "$output" = "__404__" ] +} + +@test "gh_api: returns __422__ 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 '/orgs/bad-request'" + [ "$status" -eq 0 ] + [ "$output" = "__422__" ] +} + +@test "gh_api: returns body on HTTP 200" { + _mock_curl 200 '{"login":"test-org"}' + 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 '/orgs/test-org'" + [ "$status" -eq 0 ] + [ "$output" = '{"login":"test-org"}' ] +} From 8b2549ddd3316fd115431ad33a69c6a01b87d4f7 Mon Sep 17 00:00:00 2001 From: Patrick Lewis <4015312+locus313@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:52:41 -0700 Subject: [PATCH 2/4] feat: expand test suite to 91 tests covering all scripts and lib functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_common.bats (29 tests): - Add err, configure_gh_auth, validate_token, validate_github_token URL warning, get_repo_page_count (with/without Link header), gh_api_paginate (404, 422, single-page) - Upgrade _mock_curl to use universal mock_curl.sh (handles both gh_api stdout mode and gh_api_paginate/validate_token -o/-D file mode) tests/mock_curl.sh: - Universal drop-in curl mock; response data via env vars (MOCK_CURL_CODE, MOCK_CURL_BODY, MOCK_CURL_LINK) — no quoting issues with JSON bodies test_script_validation.bats (62 tests): - Every script: missing required env vars exit 1 in the correct order - Arg parsing: --help exits 0, unknown args exit 1, recognised flags (--dry-run, --type) don't trigger Unknown argument errors - Invalid enum values: --type, DEPENDABOT_REASON, SECRET_SCANNING_RESOLUTION - github-import-repo: non-GitHub GIT_URL_PREFIX exits 1 (security guard) - github-repo-permissions-report: missing -r exits 1 ci.yml: run bats tests/ (all bats files) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- tests/mock_curl.sh | 55 ++++ tests/test_common.bats | 194 +++++++++++- tests/test_script_validation.bats | 475 ++++++++++++++++++++++++++++++ 4 files changed, 716 insertions(+), 10 deletions(-) create mode 100755 tests/mock_curl.sh create mode 100644 tests/test_script_validation.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04bc2ec..bb6a722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,4 +47,4 @@ jobs: run: sudo apt-get update -qq && sudo apt-get install -y bats - name: Run unit tests - run: bats tests/test_common.bats + run: bats tests/ diff --git a/tests/mock_curl.sh b/tests/mock_curl.sh new file mode 100755 index 0000000..fccc3b4 --- /dev/null +++ b/tests/mock_curl.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# ============================================================================= +# mock_curl.sh +# +# Universal drop-in curl mock for bats tests. Copy into a directory that is +# prepended to PATH; the real curl is then shadowed for the duration of a test. +# +# Response data is read from environment variables so callers never embed +# special characters in the script body: +# +# MOCK_CURL_CODE HTTP status code to return (default: 200) +# MOCK_CURL_BODY Response body (default: empty) +# MOCK_CURL_LINK Full URL for Link: next header — set to make the +# response look like a paginated "non-last" page; +# leave empty (default) to signal the final page +# +# Handles two calling conventions used in lib/github-common.sh: +# +# stdout mode (gh_api, get_repo_page_count) +# curl ... (no -o flag) +# Output: \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