#!/usr/bin/env bash # Regression harness for ci-queue-wait.sh's zero-status-context handling. # # Gitea's combined-status endpoint returns, for a commit with NO status # contexts (e.g. a repo with no CI configured at all): # {"state":"pending","sha":"...","total_count":0,"statuses":null,...} # -- statuses is JSON null (not []), and state is a synthetic "pending" # even though nothing is running. Captured live from a Gitea 1.22 host, # 2026-08-09 (jason.woltje/jarvis-brain, a repo with no pipeline). # # Before the fix, `payload.get("statuses", [])` received null, failed the # isinstance(list) check, and the guard reported ASSERTED_NOT_READY # state=malformed (exit 3) -- blocking every push to a CI-less repo. Had # null been tolerated, the synthetic aggregate "pending" would instead # have polled to the timeout (exit 124). The fix must: # 1. treat statuses:null as an empty list, and # 2. classify zero status VALUES as "no-status" regardless of the # synthetic aggregate state, and # 3. treat no-status for --purpose push (without --require-status) as # queue-clear (exit 0) -- a repo with no CI has no queue to wait on; # this mirrors record_cannot_assert's disposition table # (push=degraded-pass, merge=hold). # # Covers: # (a) statuses:null + synthetic state:pending, purpose=push # -> exit 0, queue-clear/no-status message (THE live fault). # (b) same payload, purpose=merge -> still fail-closed (exit 3). # (c) same payload, push + --require-status -> still fail-closed (exit 3). # (d) statuses:[] + state:"" -> same as (a) (exit 0). # (e) a real pending context -> still polls (times out, 124), # proving the relaxation didn't swallow genuine pending states. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-status}" REPO_DIR="$WORK_DIR/repo" STUB_DIR="$WORK_DIR/stubs" rm -rf "$WORK_DIR" mkdir -p "$REPO_DIR" "$STUB_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git # Minimal curl stub (same conventions as test-ci-queue-wait-branch-absent.sh): # branch lookup answers 200 with a fixed SHA; the status endpoint's payload # is selected by MOSAIC_STUB_STATUS_MODE. cat > "$STUB_DIR/curl" <<'SH' #!/usr/bin/env bash set -euo pipefail has_w=0 url="" for arg in "$@"; do case "$arg" in -w) has_w=1 ;; http://*|https://*) url="$arg" ;; esac done case "$url" in */branches/*) body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' if [[ "$has_w" == 1 ]]; then printf '%s\n200' "$body" else printf '%s' "$body" fi exit 0 ;; */status) mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}" ;; *) echo "curl stub: unrecognized URL: $url" >&2 exit 2 ;; esac case "$mode" in # Verbatim shape of the live Gitea response (repository object elided). gitea-null-statuses) body='{"state":"pending","sha":"deadbeefcafef00d0123456789abcdef01234567","total_count":0,"statuses":null,"url":"","commit_url":""}' ;; empty-statuses) body='{"state":"","statuses":[]}' ;; real-pending) body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}' ;; *) echo "curl stub: unknown mode=$mode" >&2 exit 2 ;; esac printf '%s' "$body" SH chmod +x "$STUB_DIR/curl" run_ci_queue_wait() { ( cd "$REPO_DIR" export PATH="$STUB_DIR:$PATH" export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json" export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl" export GITEA_TOKEN="stub-token" export GITEA_URL="https://git.example.test" "$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@" ) } fail=0 # (a) THE live fault: statuses:null + synthetic pending, purpose=push -> exit 0. set +e out_a=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push 2>&1) status_a=$? set -e if [[ "$status_a" -ne 0 ]]; then echo "FAIL(a): expected exit 0 for null-statuses/no-CI repo on push, got $status_a" >&2 echo "$out_a" >&2 fail=1 elif [[ "$out_a" == *"malformed"* ]]; then echo "FAIL(a): null statuses must not be classified as malformed" >&2 echo "$out_a" >&2 fail=1 elif [[ "$out_a" != *"no-status"* ]]; then echo "FAIL(a): expected a no-status/queue-clear message, got:" >&2 echo "$out_a" >&2 fail=1 fi # (b) Same payload, purpose=merge -> hold, fail-closed exit 3. set +e out_b=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose merge 2>&1) status_b=$? set -e if [[ "$status_b" -ne 3 ]]; then echo "FAIL(b): expected exit 3 for no-status on merge, got $status_b" >&2 echo "$out_b" >&2 fail=1 fi # (c) Same payload, push + --require-status -> strictness opt-in still fails. set +e out_c=$(MOSAIC_STUB_STATUS_MODE=gitea-null-statuses run_ci_queue_wait --purpose push --require-status 2>&1) status_c=$? set -e if [[ "$status_c" -ne 3 ]]; then echo "FAIL(c): expected exit 3 for --require-status with no contexts, got $status_c" >&2 echo "$out_c" >&2 fail=1 fi # (d) statuses:[] + state:"" (the shape the code always tolerated) -> exit 0 on push. set +e out_d=$(MOSAIC_STUB_STATUS_MODE=empty-statuses run_ci_queue_wait --purpose push 2>&1) status_d=$? set -e if [[ "$status_d" -ne 0 ]]; then echo "FAIL(d): expected exit 0 for empty-statuses on push, got $status_d" >&2 echo "$out_d" >&2 fail=1 fi # (e) A REAL pending context must still block: polls to timeout, exit 124. set +e out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending run_ci_queue_wait --purpose push 2>&1) status_e=$? set -e if [[ "$status_e" -ne 124 ]]; then echo "FAIL(e): expected exit 124 (timeout) for a genuinely pending context, got $status_e" >&2 echo "$out_e" >&2 fail=1 elif [[ "$out_e" != *"ci/woodpecker=running"* ]]; then echo "FAIL(e): expected the pending context to be reported, got:" >&2 echo "$out_e" >&2 fail=1 fi if [[ "$fail" -eq 0 ]]; then echo "ci-queue-wait no-status regression passed (5/5 cases)" fi exit "$fail"