diff --git a/.standards-check-ignore b/.standards-check-ignore index fbe334d..a43a5e0 100644 --- a/.standards-check-ignore +++ b/.standards-check-ignore @@ -1,3 +1,12 @@ -# Repos that never get standards-check.yml installed by -# bulk-install-standards-check.sh. One owner/repo per line; # is a comment. -nightowlstudiollc/networth-agent +# Repos exempt from the standards-check policy (caller + required check). +# Read by bulk-install-standards-check.sh. +# +# Format: one entry per line, "owner/repo # reason". The reason is required; +# an entry without one is reported as ERROR. Lines starting with # are +# comments. +# +# Archived repos and forks are skipped by the classifier on their own +# (ARCHIVED, FORK). A fork is listed here too only to keep the written reason +# next to the policy. +nightowlstudiollc/networth-agent # archived; nothing runs on it +twistedmelonman/Instapaper-MCP # fork of an upstream project; its CI belongs to upstream, and local commits would diverge the fork diff --git a/bulk-install-standards-check.sh b/bulk-install-standards-check.sh index a0948ff..fac86fd 100755 --- a/bulk-install-standards-check.sh +++ b/bulk-install-standards-check.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Install the standards-check.yml caller stub across the fleet. # -# Classifies every non-archived repo of --owners (plus --extra-repos) as -# MISSING / CURRENT / DIFFERS / IGNORED / ARCHIVED / ERROR and, with --apply, +# Classifies every repo of --owners (plus --extra-repos) by policy +# (classes: see --help) and, with --apply, # writes the canonical stub (standards/caller-stub.yml) into MISSING repos. # # --mode=pr branch chore/standards-check-stub + Contents-API put + PR @@ -16,22 +16,56 @@ set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -GH="${BULK_GH:-gh}" +# The real binary first: the dotfiles gh wrapper overrides a GH_TOKEN= prefix. +if [[ -n "${BULK_GH:-}" ]]; then + GH="${BULK_GH}" +elif [[ -x /opt/homebrew/bin/gh ]]; then + GH=/opt/homebrew/bin/gh +else + GH=gh +fi STUB_FILE="${BULK_STUB_FILE:-${here}/standards/caller-stub.yml}" IGNORE_FILE="${BULK_IGNORE_FILE:-${here}/.standards-check-ignore}" +# Sanity floor for live non-fork repos. Measured 38 on 2026-10-12; fewer +# means a truncated or wrong-token listing. +MIN_REPOS="${BULK_MIN_REPOS:-35}" INSTALL_PATH=".github/workflows/standards-check.yml" +REQUIRED_CHECK="standards-check / run-standards-check" BRANCH="chore/standards-check-stub" SESSION="https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf" APPLY=false MODE="pr" ONLY="" -OWNERS="smartwatermelon,nightowlstudiollc" -EXTRA="twistedmelonman/dotfiles,twistedmelonman/claude-config,twistedmelonman/personify,twistedmelonman/huddle-transcribe,twistedmelonman/projectinsomnia" +OWNERS="smartwatermelon,nightowlstudiollc,twistedmelonman" +EXTRA="" usage() { cat < its token's env var name; empty means gh's default token. +token_var() { + case "$1" in + smartwatermelon) echo GH_TOKEN_SWM ;; + nightowlstudiollc) echo GH_TOKEN_NOS ;; + twistedmelonman) echo GH_TOKEN_TWM ;; + *) echo "" ;; + esac +} + +# ghx OWNER ARGS...: gh with OWNER's token, in the child's env only. +ghx() { + local owner="$1" var + shift + var="$(token_var "${owner}")" + if [[ -n "${var}" ]]; then + GH_TOKEN="${!var}" "${GH}" "$@" + else + "${GH}" "$@" + fi +} + +# An unset token would fall back to another identity and read a partial fleet. +check_tokens() { + local owner r var + local -a owners extras + IFS=',' read -r -a owners <<<"${OWNERS}" + IFS=',' read -r -a extras <<<"${EXTRA}" + for r in "${extras[@]}"; do + [[ -n "${r}" ]] && owners+=("${r%%/*}") + done + for owner in "${owners[@]}"; do + [[ -n "${owner}" ]] || continue + var="$(token_var "${owner}")" + if [[ -n "${var}" && -z "${!var:-}" ]]; then + echo "FATAL: ${var} is not set; cannot read ${owner}'s repos with its own token" >&2 + exit 2 + fi + done +} + +# Prints the reason for a listed repo, or returns 1. Matches the first field +# only, so a trailing reason still matches. +ignore_reason() { [[ -f "${IGNORE_FILE}" ]] || return 1 - grep -v '^[[:space:]]*#' "${IGNORE_FILE}" | grep -qx "$1" + local line name rest + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + name="${line%%[[:space:]]*}" + [[ "${name}" == "$1" ]] || continue + rest="${line#"${name}"}" + rest="${rest#"${rest%%[![:space:]]*}"}" + rest="${rest#\#}" + rest="${rest#"${rest%%[![:space:]]*}"}" + printf '%s' "${rest}" + return 0 + done <"${IGNORE_FILE}" + return 1 } -# Emits "owner/repoarchived(true|false)" lines. +# Emits "owner/repoarchivedforkdefault_branch" lines. list_repos() { - local owner r + local owner r type body rows local -a owners extras + # The owner reaches jq through env, not --arg, so the program has no "$". + local jqprog='.[] | select(.owner.login == env.LIST_OWNER) + | "\(.full_name)\t\(.archived)\t\(.fork)\t\(.default_branch)"' IFS=',' read -r -a owners <<<"${OWNERS}" for owner in "${owners[@]}"; do [[ -n "${owner}" ]] || continue - "${GH}" repo list "${owner}" --json name,isArchived --limit 200 | - jq -r --arg o "${owner}" '.[] | "\($o)/\(.name)\t\(.isArchived)"' + type="$(ghx "${owner}" api "users/${owner}" --jq .type)" || { + echo "FATAL: cannot look up account type of ${owner}" >&2 + return 2 + } + # users//repos omits private repos, so a user owner reads user/repos. + if [[ "${type}" == "User" ]]; then + body="$(ghx "${owner}" api --paginate 'user/repos?affiliation=owner&per_page=100')" || { + echo "FATAL: listing ${owner}'s repos failed" >&2 + return 2 + } + else + body="$(ghx "${owner}" api --paginate "orgs/${owner}/repos?per_page=100")" || { + echo "FATAL: listing ${owner}'s repos failed" >&2 + return 2 + } + fi + # One JSON array per page; rows are filtered to the owner. + rows="$(LIST_OWNER="${owner}" jq -r "${jqprog}" <<<"${body}")" || { + echo "FATAL: cannot parse ${owner}'s repo listing" >&2 + return 2 + } + # Empty usually means the wrong owner's token. + if [[ -z "${rows}" ]]; then + echo "FATAL: ${owner} listing returned no repos (wrong token, or no access)" >&2 + return 2 + fi + printf '%s\n' "${rows}" done if [[ -n "${EXTRA}" ]]; then IFS=',' read -r -a extras <<<"${EXTRA}" for r in "${extras[@]}"; do - [[ -n "${r}" ]] && printf '%s\tfalse\n' "${r}" + [[ -n "${r}" ]] && printf '%s\tfalse\tfalse\t\n' "${r}" done fi + return 0 } # owner/repo -> prints the file's content, base64 with no newlines. @@ -95,7 +214,7 @@ existing_b64() { # stderr is captured, not discarded: it carries the "(HTTP )" line that # distinguishes a real 404 from every other failure. Real `gh api` prints # "gh: Not Found (HTTP 404)" there and exits 1 (measured 2026-09-08). - body="$("${GH}" api "repos/$1/contents/${INSTALL_PATH}" 2>&1)" || rc=$? + body="$(ghx "${1%%/*}" api "repos/$1/contents/${INSTALL_PATH}" 2>&1)" || rc=$? if [[ "${rc}" -ne 0 ]]; then if [[ "${body}" == *"(HTTP 404)"* ]]; then return 1 @@ -106,14 +225,41 @@ existing_b64() { jq -r '.content' <<<"${body}" | tr -d '\n' | base64 -d | base64 | tr -d '\n' } -default_branch() { "${GH}" api "repos/$1" --jq .default_branch; } +# -> required | not-required | unavailable, or return 2. +# A bare 403 is an error: a wrong token returns one too. +required_state() { + local body rc=0 + body="$(ghx "${1%%/*}" api "repos/$1/branches/$2/protection" 2>&1)" || rc=$? + if [[ "${rc}" -ne 0 ]]; then + # Classic protection only; a ruleset-only requirement reads not-required. + if [[ "${body}" == *"Branch not protected"*"(HTTP 404)"* ]]; then + echo not-required + return 0 + fi + if [[ "${body}" == *"Upgrade to GitHub Pro"*"(HTTP 403)"* ]]; then + echo unavailable + return 0 + fi + printf '%s' "${body}" + return 2 + fi + if jq -e --arg c "${REQUIRED_CHECK}" \ + '[(.required_status_checks.contexts // [])[], ((.required_status_checks.checks // [])[] | .context)] | index($c) != null' \ + <<<"${body}" >/dev/null; then + echo required + else + echo not-required + fi +} + +default_branch() { ghx "${1%%/*}" api "repos/$1" --jq .default_branch; } put_file() { # owner/repo branch [blob-sha] local -a sha_arg=() # The Contents API requires the current blob sha to overwrite an existing # file. A retry after a partial run finds the file already on the branch. [[ -n "${3:-}" ]] && sha_arg=(-f "sha=$3") - "${GH}" api -X PUT "repos/$1/contents/${INSTALL_PATH}" \ + ghx "${1%%/*}" api -X PUT "repos/$1/contents/${INSTALL_PATH}" \ -f message="ci: add standards-check.yml caller stub (non-required) Installs the deterministic standards check as a non-required status. @@ -140,23 +286,23 @@ install_push() { # owner/repo # Retry-safe: a previous partial run may have left the branch, and the file on # it, behind. Reuse both rather than failing the repo on a duplicate ref POST. install_pr() { # owner/repo - local db sha blob="" + local db sha blob="" owner="${1%%/*}" db="$(default_branch "$1")" || return 1 [[ -n "${db}" ]] || return 1 - if "${GH}" api "repos/$1/git/ref/heads/${BRANCH}" >/dev/null 2>&1; then + if ghx "${owner}" api "repos/$1/git/ref/heads/${BRANCH}" >/dev/null 2>&1; then # The branch already exists; the file may too, in which case the PUT needs # its blob sha. A 404 here just means the branch is empty of the stub. # The ref goes in the query string, not in -f: `gh api` switches the method # to POST as soon as any -f parameter is added (`gh api --help`), which # would turn this read into a failing write and silently drop the sha. - blob="$("${GH}" api "repos/$1/contents/${INSTALL_PATH}?ref=${BRANCH}" --jq .sha 2>/dev/null)" || blob="" + blob="$(ghx "${owner}" api "repos/$1/contents/${INSTALL_PATH}?ref=${BRANCH}" --jq .sha 2>/dev/null)" || blob="" else - sha="$("${GH}" api "repos/$1/git/ref/heads/${db}" --jq .object.sha)" || return 1 + sha="$(ghx "${owner}" api "repos/$1/git/ref/heads/${db}" --jq .object.sha)" || return 1 [[ -n "${sha}" ]] || return 1 - "${GH}" api -X POST "repos/$1/git/refs" -f ref="refs/heads/${BRANCH}" -f sha="${sha}" >/dev/null || return 1 + ghx "${owner}" api -X POST "repos/$1/git/refs" -f ref="refs/heads/${BRANCH}" -f sha="${sha}" >/dev/null || return 1 fi put_file "$1" "${BRANCH}" "${blob}" || return 1 - "${GH}" pr create --repo "$1" --head "${BRANCH}" --base "${db}" \ + ghx "${owner}" pr create --repo "$1" --head "${BRANCH}" --base "${db}" \ --title "ci: add standards-check.yml caller stub (non-required)" \ --body "Installs \`standards-check.yml\` as a NON-required check. Branch protection is unchanged; W3 flips it to required once this repo is green. @@ -175,52 +321,95 @@ install_repo() { # mode owner/repo esac } +line() { # class repo [detail] + printf '%-15s %s%s\n' "$1" "$2" "${3:+ $3}" +} + +check_tokens + # Enumerate once into a file: a process substitution here would mask -# list_repos' exit status, so a `gh repo list` failure would read as an empty -# fleet and the script would exit 0 having done nothing. +# list_repos' exit status, so a listing failure would read as an empty fleet +# and the script would exit 0 having done nothing. repo_list="$(mktemp)" trap 'rm -f "${repo_list}"' EXIT -list_repos >"${repo_list}" +list_repos >"${repo_list}" || exit 2 + +live="$(awk -F'\t' '$2 == "false" && $3 == "false"' "${repo_list}" | wc -l | tr -d ' ')" +if [[ "${live}" -lt "${MIN_REPOS}" ]]; then + echo "FATAL: enumerated ${live} live non-fork repos, below the sanity floor of ${MIN_REPOS}." >&2 + echo " A listing is probably truncated or used the wrong owner's token." >&2 + exit 2 +fi rc=0 -while IFS=$'\t' read -r repo archived; do +while IFS=$'\t' read -r repo archived fork branch; do [[ -n "${repo}" ]] || continue if [[ -n "${ONLY}" && "${repo}" != "${ONLY}" ]]; then continue; fi if [[ "${archived}" == "true" ]]; then - printf 'ARCHIVED %s\n' "${repo}" + line ARCHIVED "${repo}" "(skipped)" continue fi - if is_ignored "${repo}"; then - printf 'IGNORED %s\n' "${repo}" + if [[ "${fork}" == "true" ]]; then + line FORK "${repo}" "(out of policy scope; skipped)" + continue + fi + if reason="$(ignore_reason "${repo}")"; then + if [[ -z "${reason}" ]]; then + line ERROR "${repo}" "listed in the ignore file without a reason" + rc=1 + else + line IGNORED "${repo}" "${reason}" + fi continue fi lookup_rc=0 current="$(existing_b64 "${repo}")" || lookup_rc=$? case "${lookup_rc}" in 0) - if [[ "${current}" == "${STUB_B64}" ]]; then - printf 'CURRENT %s\n' "${repo}" - else - printf 'DIFFERS %s (has a non-canonical stub; not touched)\n' "${repo}" + if [[ "${current}" == "${STUB_B64}" ]]; then stub="canonical stub"; else stub="non-canonical caller; not touched"; fi + if [[ -z "${branch}" ]]; then + branch="$(default_branch "${repo}" 2>&1)" || { + line ERROR "${repo}" "default branch lookup: ${branch//$'\n'/ }" + rc=1 + continue + } + fi + req_rc=0 + req="$(required_state "${repo}" "${branch}")" || req_rc=$? + if [[ "${req_rc}" -ne 0 ]]; then + line ERROR "${repo}" "protection lookup: ${req//$'\n'/ }" + rc=1 + continue fi + case "${req}" in + required) + if [[ "${stub}" == "canonical stub" ]]; then + line CURRENT "${repo}" "(${stub}; check required)" + else + line DIFFERS "${repo}" "(${stub}; check required)" + fi ;; + not-required) line CALLER-ONLY "${repo}" "(${stub}; check not required on ${branch})" ;; + unavailable) line CANNOT-REQUIRE "${repo}" "(${stub}; Free-plan private repo, no branch protection)" ;; + *) line ERROR "${repo}" "unexpected protection state: ${req}"; rc=1 ;; + esac continue ;; 1) : ;; # confirmed 404: the file is absent, fall through to MISSING *) # One line per repo is the output contract; API errors are multi-line. - printf 'ERROR %s %s\n' "${repo}" "${current//$'\n'/ }" + line ERROR "${repo}" "${current//$'\n'/ }" rc=1 continue ;; esac if ! ${APPLY}; then - printf 'MISSING %s (dry run; would %s)\n' "${repo}" "${MODE}" + line MISSING "${repo}" "(dry run; would ${MODE})" continue fi if out="$(install_repo "${MODE}" "${repo}" 2>&1)"; then - printf 'MISSING %s -> %s\n' "${repo}" "${out}" + line MISSING "${repo}" "-> ${out}" else - printf 'ERROR %s %s\n' "${repo}" "${out//$'\n'/ }" + line ERROR "${repo}" "${out//$'\n'/ }" rc=1 fi done <"${repo_list}" diff --git a/tests/stub-gh/gh b/tests/stub-gh/gh index 8b88c46..8c7d1a9 100755 --- a/tests/stub-gh/gh +++ b/tests/stub-gh/gh @@ -1,6 +1,12 @@ #!/usr/bin/env bash # Stub gh for bulk-install-standards-check.sh tests. Fixture layout: -# ${STUB_DIR}/repos/.json -> output of `gh repo list --json ...` +# ${STUB_DIR}/repos/.json -> REST listing of orgs//repos +# ${STUB_DIR}/types/ -> account type ("User"); default Organization +# ${STUB_DIR}/user-repos.json -> REST listing of user/repos (user owners) +# ${STUB_DIR}/protection/__ -> branch protection JSON (200) +# absent: 404 "Branch not protected" +# ${STUB_DIR}/protection-free/__ -> 403 "Upgrade to GitHub Pro" (Free plan) +# ${STUB_DIR}/protection-fail/__ -> bare 403 (e.g. wrong owner's token) # ${STUB_DIR}/files/__ -> existing stub content, if the repo has one # ${STUB_DIR}/fail/__ -> contents GET fails 403 instead of 404 # ${STUB_DIR}/branch-exists/__ -> the chore/standards-check-stub ref exists @@ -10,18 +16,13 @@ # Failure output copies real `gh api`: the JSON body on stdout, a # "gh: (HTTP )" line on stderr, exit 1. Measured 2026-09-08 # against `gh api repos/smartwatermelon/does-not-exist-xyz-9876`. -# Archived state comes from the isArchived field in repos/.json. +# Archived and fork state come from the archived/fork fields of the listing. set -euo pipefail : "${STUB_DIR:?}" # One line per invocation: arguments carry multi-line bodies (commit message, # PR body), so escape newlines rather than break the log's one-line contract. printf '%s\n' "${*//$'\n'/\\n}" >>"${STUB_DIR}/calls.log" case "$1" in - repo) - # gh repo list --json name,isArchived,defaultBranchRef --limit 200 - owner="$3" - cat "${STUB_DIR}/repos/${owner}.json" - ;; api) shift method="GET" @@ -55,6 +56,35 @@ case "$1" in fi } case "${method} ${path}" in + "GET users/"*) + owner="${path#users/}" + t="Organization" + [[ -f "${STUB_DIR}/types/${owner}" ]] && t="$(cat "${STUB_DIR}/types/${owner}")" + printf '{"type":"%s"}\n' "${t}" | emit ;; + "GET orgs/"*"/repos") + owner="${path#orgs/}"; owner="${owner%%/*}" + emit <"${STUB_DIR}/repos/${owner}.json" ;; + "GET user/repos") + emit <"${STUB_DIR}/user-repos.json" ;; + "GET repos/"*"/branches/"*"/protection") + rest="${path#repos/}"; owner="${rest%%/*}"; rest="${rest#*/}"; repo="${rest%%/*}" + if [[ -f "${STUB_DIR}/protection-free/${owner}__${repo}" ]]; then + printf '{"message":"Upgrade to GitHub Pro or make this repository public to enable this feature.","status":"403"}' + echo 'gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)' >&2 + exit 1 + fi + if [[ -f "${STUB_DIR}/protection-fail/${owner}__${repo}" ]]; then + printf '{"message":"Resource not accessible by personal access token","status":"403"}' + echo 'gh: Resource not accessible by personal access token (HTTP 403)' >&2 + exit 1 + fi + if [[ -f "${STUB_DIR}/protection/${owner}__${repo}" ]]; then + emit <"${STUB_DIR}/protection/${owner}__${repo}" + else + printf '{"message":"Branch not protected","status":"404"}' + echo 'gh: Branch not protected (HTTP 404)' >&2 + exit 1 + fi ;; "GET repos/"*"/contents/.github/workflows/standards-check.yml") # repos///contents/... rest="${path#repos/}"; owner="${rest%%/*}"; rest="${rest#*/}"; repo="${rest%%/*}" diff --git a/tests/test-bulk-install-standards-check.sh b/tests/test-bulk-install-standards-check.sh index 7c927d7..c15df87 100755 --- a/tests/test-bulk-install-standards-check.sh +++ b/tests/test-bulk-install-standards-check.sh @@ -10,25 +10,30 @@ _bad() { echo " FAIL $1"; fail=$((fail + 1)); } _fixture() { # creates a fresh STUB_DIR with two owners, four repos STUB_DIR="$(mktemp -d)"; export STUB_DIR - mkdir -p "${STUB_DIR}/repos" "${STUB_DIR}/files" + mkdir -p "${STUB_DIR}/repos" "${STUB_DIR}/files" "${STUB_DIR}/protection" "${STUB_DIR}/types" cat >"${STUB_DIR}/repos/acme.json" <<'EOF' -[{"name":"alpha","isArchived":false,"defaultBranchRef":{"name":"main"}}, - {"name":"beta","isArchived":false,"defaultBranchRef":{"name":"main"}}, - {"name":"old","isArchived":true,"defaultBranchRef":{"name":"main"}}] +[{"full_name":"acme/alpha","owner":{"login":"acme"},"archived":false,"fork":false,"default_branch":"main"}, + {"full_name":"acme/beta","owner":{"login":"acme"},"archived":false,"fork":false,"default_branch":"main"}, + {"full_name":"acme/old","owner":{"login":"acme"},"archived":true,"fork":false,"default_branch":"main"}] EOF cat >"${STUB_DIR}/repos/nite.json" <<'EOF' -[{"name":"gamma","isArchived":false,"defaultBranchRef":{"name":"main"}}] +[{"full_name":"nite/gamma","owner":{"login":"nite"},"archived":false,"fork":false,"default_branch":"main"}] EOF cp "${stub}" "${STUB_DIR}/files/acme__beta" # beta already CURRENT printf 'name: Different\n' >"${STUB_DIR}/files/nite__gamma" # gamma DIFFERS - printf 'acme/alpha-ignored\n' >"${STUB_DIR}/ignore" + _required acme__beta + _required nite__gamma + printf 'acme/alpha-ignored # fixture reason\n' >"${STUB_DIR}/ignore" : >"${STUB_DIR}/calls.log" } -# --extra-repos "" is required: the script's default EXTRA names five real -# twistedmelonman repos, which the fixture does not know about. They would -# read as MISSING and add PUTs that the counts below do not expect. -run() { BULK_GH="${here}/stub-gh/gh" BULK_STUB_FILE="${stub}" BULK_IGNORE_FILE="${STUB_DIR}/ignore" bash "${script}" --owners acme,nite --extra-repos "" "$@"; } +_required() { # __: protection lists the standards check + printf '{"required_status_checks":{"contexts":["standards-check / run-standards-check"],"checks":[{"context":"standards-check / run-standards-check"}]}}\n' \ + >"${STUB_DIR}/protection/$1" +} + +# Floor 1: the real floor is sized for the real fleet. Case 14 tests it. +run() { BULK_GH="${here}/stub-gh/gh" BULK_STUB_FILE="${stub}" BULK_IGNORE_FILE="${STUB_DIR}/ignore" BULK_MIN_REPOS="${MIN:-1}" bash "${script}" --owners "${OWNERS:-acme,nite}" --extra-repos "" "$@"; } # 1. dry run classifies every repo and writes nothing _fixture @@ -62,9 +67,9 @@ if grep -q 'PUT repos/nite/gamma' "${STUB_DIR}/calls.log"; then _bad "DIFFERS wa # 5. ignore file honored; --only restricts _fixture -printf 'acme/alpha\n' >"${STUB_DIR}/ignore" +printf 'acme/alpha # kept out on purpose\n' >"${STUB_DIR}/ignore" out="$(run 2>&1)" || true -if grep -q '^IGNORED *acme/alpha' <<<"${out}"; then _ok "ignore file honored"; else _bad "ignore file"; fi +if grep -q '^IGNORED *acme/alpha kept out on purpose$' <<<"${out}"; then _ok "ignore file honored, reason printed"; else _bad "ignore file: ${out}"; fi _fixture out="$(run --only nite/gamma 2>&1)" || true if grep -q 'acme/' <<<"${out}"; then _bad "--only leaked other repos"; else _ok "--only restricts"; fi @@ -124,9 +129,87 @@ if grep -q '^pr create' "${STUB_DIR}/calls.log"; then _bad "PR opened after a fa # 11. --extra-repos adds explicit owner/repo entries outside --owners _fixture -mkdir -p "${STUB_DIR}/repos"; printf '[]\n' >"${STUB_DIR}/repos/solo.json" out="$(run --extra-repos solo/thing 2>&1)" || true if grep -q '^MISSING *solo/thing' <<<"${out}"; then _ok "--extra-repos included"; else _bad "--extra-repos: ${out}"; fi +# 12. forks are FORK, never MISSING, and never written +_fixture +cat >"${STUB_DIR}/repos/nite.json" <<'EOF' +[{"full_name":"nite/gamma","owner":{"login":"nite"},"archived":false,"fork":false,"default_branch":"main"}, + {"full_name":"nite/forked","owner":{"login":"nite"},"archived":false,"fork":true,"default_branch":"main"}] +EOF +out="$(run --apply --mode=push 2>&1)" || true +if grep -q '^FORK *nite/forked' <<<"${out}"; then _ok "fork is FORK"; else _bad "fork classification: ${out}"; fi +if grep -q 'PUT repos/nite/forked' "${STUB_DIR}/calls.log"; then _bad "fork was written"; else _ok "fork not written"; fi + +# 13. caller present: not required / Free plan / bare 403 +_fixture +rm "${STUB_DIR}/protection/acme__beta" +out="$(run 2>&1)" || true +if grep -q '^CALLER-ONLY *acme/beta' <<<"${out}"; then _ok "unprotected caller is CALLER-ONLY"; else _bad "caller-only: ${out}"; fi +_fixture +printf '{"required_status_checks":{"contexts":["other"],"checks":[{"context":"other"}]}}\n' >"${STUB_DIR}/protection/acme__beta" +out="$(run 2>&1)" || true +if grep -q '^CALLER-ONLY *acme/beta' <<<"${out}"; then _ok "protected without the check is CALLER-ONLY"; else _bad "caller-only (other check): ${out}"; fi +_fixture +mkdir -p "${STUB_DIR}/protection-free"; : >"${STUB_DIR}/protection-free/acme__beta" +out="$(run 2>&1)" || true +if grep -q '^CANNOT-REQUIRE *acme/beta' <<<"${out}"; then _ok "Free-plan private is CANNOT-REQUIRE"; else _bad "cannot-require: ${out}"; fi +_fixture +mkdir -p "${STUB_DIR}/protection-fail"; : >"${STUB_DIR}/protection-fail/acme__beta" +rc=0 +out="$(run 2>&1)" || rc=$? +if grep -q '^ERROR *acme/beta protection lookup' <<<"${out}"; then _ok "bare 403 on protection is ERROR"; else _bad "bare 403: ${out}"; fi +if [[ "${rc}" == "1" ]]; then _ok "protection ERROR exits 1"; else _bad "protection ERROR exit ${rc}"; fi + +# 14. known-bad: a listing that returns too few repos must fail, write nothing +_fixture +rc=0 +out="$(MIN=10 run --apply --mode=push 2>&1)" || rc=$? +if [[ "${rc}" == "2" ]] && grep -q 'below the sanity floor' <<<"${out}"; then _ok "short listing fails the floor"; else _bad "floor: rc=${rc} ${out}"; fi +if grep -q 'PUT' "${STUB_DIR}/calls.log"; then _bad "short listing still wrote"; else _ok "short listing wrote nothing"; fi +if grep -qE '^(MISSING|CURRENT|DIFFERS) ' <<<"${out}"; then _bad "short listing still classified repos"; else _ok "short listing classified nothing"; fi + +# 15. known-bad: one owner's listing is empty (wrong token) -> fatal +_fixture +printf '[]\n' >"${STUB_DIR}/repos/nite.json" +rc=0 +out="$(run --apply --mode=push 2>&1)" || rc=$? +if [[ "${rc}" == "2" ]] && grep -q 'nite listing returned no repos' <<<"${out}"; then _ok "empty owner listing is fatal"; else _bad "empty listing: rc=${rc} ${out}"; fi +if grep -q 'PUT' "${STUB_DIR}/calls.log"; then _bad "empty listing still wrote"; else _ok "empty listing wrote nothing"; fi + +# 16. rows owned by someone else do not count (wrong token returns other repos) +_fixture +printf '[{"full_name":"x/y","owner":{"login":"x"},"archived":false,"fork":false,"default_branch":"main"}]\n' >"${STUB_DIR}/repos/nite.json" +rc=0 +out="$(run 2>&1)" || rc=$? +if [[ "${rc}" == "2" ]]; then _ok "foreign rows are filtered out"; else _bad "foreign rows: rc=${rc} ${out}"; fi + +# 17. a user owner is listed through user/repos, not orgs/ +_fixture +printf 'User\n' >"${STUB_DIR}/types/solo" +printf '[{"full_name":"solo/priv","owner":{"login":"solo"},"archived":false,"fork":false,"default_branch":"main"}]\n' >"${STUB_DIR}/user-repos.json" +out="$(OWNERS=acme,solo run 2>&1)" || true +if grep -q '^MISSING *solo/priv' <<<"${out}"; then _ok "user owner listed"; else _bad "user owner: ${out}"; fi +if grep -q 'orgs/solo' "${STUB_DIR}/calls.log"; then _bad "user owner listed via orgs/"; else _ok "user owner not listed via orgs/"; fi + +# 18. an ignore entry without a reason is an ERROR, not a silent skip +_fixture +printf 'acme/alpha\n' >"${STUB_DIR}/ignore" +rc=0 +out="$(run 2>&1)" || rc=$? +if grep -q '^ERROR *acme/alpha listed in the ignore file without a reason' <<<"${out}" && [[ "${rc}" == "1" ]]; then _ok "reasonless ignore is ERROR"; else _bad "reasonless ignore: rc=${rc} ${out}"; fi + +# 19. a mapped owner with no token set is fatal before any API call +_fixture +rc=0 +out="$(GH_TOKEN_NOS="" OWNERS=nightowlstudiollc run 2>&1)" || rc=$? +if [[ "${rc}" == "2" ]] && grep -q 'GH_TOKEN_NOS is not set' <<<"${out}"; then _ok "missing owner token is fatal"; else _bad "missing token: rc=${rc} ${out}"; fi +if [[ -s "${STUB_DIR}/calls.log" ]]; then _bad "API called without a token"; else _ok "no API call without a token"; fi +_fixture +rc=0 +out="$(GH_TOKEN_TWM="" run --extra-repos twistedmelonman/x 2>&1)" || rc=$? +if [[ "${rc}" == "2" ]] && grep -q 'GH_TOKEN_TWM is not set' <<<"${out}"; then _ok "extra repo's owner token checked"; else _bad "extra owner token: rc=${rc} ${out}"; fi + echo "${pass} passed, ${fail} failed" [[ "${fail}" -eq 0 ]]