#!/usr/bin/env bash # Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh # (issue #188, term 1). set -u, not -e. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" # shellcheck source=lib/forge.sh . "$ROOT/lib/forge.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT eq() { local want="$1" got shift got="$("$@")" || return 1 [ "$got" = "$want" ] } # --- forge_select: exactly one backend, chosen deliberately ------------- check "select github loads the github backend" 0 "" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null' check "select forgejo loads the forgejo backend" 0 "" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null' check "select refuses an unknown forge" 1 "unknown forge" \ bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab' # shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process check "select with no argument reads the environment" 0 "" \ bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]' # --- the page-size contract, both dialects ------------------------------ # The trap, measured 2026-08-02: each forge silently ignores the OTHER's # page-size parameter and answers HTTP 200 with fewer items. # # ?per_page=100 GitHub 100 Forgejo 30 (ignored) # ?limit=100 GitHub 30 Forgejo 50 (capped) # # So no call site names one, and these two functions are the only places # that decide. Pure on purpose: the contract is testable without a network. . "$ROOT/lib/forge-github.sh" . "$ROOT/lib/forge-forgejo.sh" check "github: a bare path gets a query" 0 "" \ eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues' check "github: an existing query is preserved" 0 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open' check "forgejo: a bare path gets a query" 0 "" \ eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1 check "forgejo: an existing query is preserved" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2 # A caller that names a page size anyway must not be able to reintroduce the # truncation — the parameter is stripped in BOTH dialects, on both backends, # because the whole point is that the boundary decides and the call site # cannot override it by accident. check "github strips a stray per_page" 0 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30' check "github strips a stray limit" 0 "" \ eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100' check "forgejo strips a stray per_page" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1 check "forgejo strips a stray limit" 0 "" \ eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1 check "stripping the only parameter leaves a clean query" 0 "" \ eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1 # --- the forgejo gather: complete, or loudly refused -------------------- # curl is stubbed as a function so these are hermetic. Each case writes the # headers and body a real Forgejo would. # fake_forge — install a curl stub serving as # successive page bodies, declaring in x-total-count. An empty # string omits the header entirely (@kimi's #4699 case). A comma-separated # spec declares a DIFFERENT total per page ("4,9"), which is # @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712): # a server whose count moves under the walk cannot have been read whole. fake_forge() { FAKE_TOTAL="$1"; shift FAKE_PAGES=("$@") FAKE_CALLS=0 # shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api curl() { local hdr="" out="" url="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; -*) ;; *) url="$1" ;; esac shift done local page=1 case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac local total="$FAKE_TOTAL" case "$FAKE_TOTAL" in *,*) total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")" [ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)" ;; esac { printf 'HTTP/1.1 200 OK\r\n' [ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total" printf '\r\n' } >"$hdr" if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out" else printf '[]' >"$out" fi FAKE_CALLS=$((FAKE_CALLS + 1)) return 0 } } export CEREMONY_FORGE_API=https://forge.example/api/v1 # One page, and the count agrees with the declared total. fake_forge 2 '[{"number":1},{"number":2}]' check "a complete single-page gather returns its items" 0 "" \ eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # Two pages that add up. The walk must not stop at the first page merely # because it came back non-empty — rig has 137 issues across 3 pages, which # is the case this models. fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]' check "a multi-page gather walks every page" 0 "" \ eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # The whole reason the assert exists: a server that declares more than it # hands over must not produce a "successful" partial sweep. fake_forge 137 '[{"number":1},{"number":2}]' check "a short gather is refused, not reconciled" 1 "incomplete gather" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and the refusal names both counts" 1 "collected 2 of 137" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able # to degrade silently either. A Forgejo that does not expose x-total-count # leaves the assert with nothing to compare, and an assert that cannot run # must refuse rather than pass. fake_forge '' '[{"number":1},{"number":2}]' check "a missing x-total-count refuses" 1 "did not send x-total-count" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which # an unprovable read could still have been reported as a whole one — the # guard leaking the failure class it was built to stop, which is why they # are refusals rather than warnings. # A total that is not a number went straight into arithmetic. Reproduced on # ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as # the total. fake_forge 'not-a-number' '[{"number":1}]' check "a non-numeric total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' check "...and the refusal quotes what arrived" 1 "not-a-number" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge '12x' '[{"number":1}]' check "a partly-numeric total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge '-3' '[{"number":1}]' check "a negative total is refused" 1 "not a non-negative integer" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A total that MOVES under the walk. The loop read it once, so a board # changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and # the walk stopped at 4 believing itself complete. fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]' check "a total that changes between pages is refused" 1 "changed between pages" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # The distinguishing text, not a substring that survives losing half the # message: "4" alone stayed green if the later total vanished, which is what # @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734) # both caught. A test named "names BOTH totals" must fail when one goes. check "...and the refusal names both totals" 1 "4 then 9" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A 200 whose body is not a collection. `length` on a non-array counted 0, # so an object or a scalar arriving where a list belongs read as a complete # EMPTY collection when the declared total was 0 — silence dressed as a # clean sweep. fake_forge 0 '{"message":"Not found"}' check "a non-array body is refused" 1 "did not return a collection" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' fake_forge 0 '"a string"' check "a scalar body is refused" 1 "did not return a collection" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # A genuinely empty collection is still fine — the refusal must not fire on # a repo that legitimately has nothing. fake_forge 0 '[]' check "an empty collection is not an error" 0 "" \ forge_api --paginate 'repos/o/r/issues' --jq '.[].number' # --- HTTP failures are named, not swallowed ----------------------------- # gh exits non-zero on an HTTP error; curl does not without -f, and -f # discards the body that explains why. So the status is read explicitly. fake_forge 1 '[{"number":1}]' # shellcheck disable=SC2317 # invoked indirectly, by forge_api curl() { local hdr="" out="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac shift done printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr" printf '{"message":"Not found"}' >"$out" return 0 } check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999' check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999' # --- forge_issue_edit: a typo must not become a green no-op -------------- # @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is # given to `gh`, which fails on a flag it does not know. Dropping it here # instead turned a mis-typed port site into a mutation that silently did not # happen — this issue's own failure class, arriving inside the fix for it. check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label # --- forge_label_create: an upsert, like gh's --force -------------------- # bootstrap_labels creates every declared label on EVERY workflow_dispatch, # so a plain POST onto an existing name aborts the bootstrap under set -e # from the second dispatch onward (#4743). WRITES="$TMP/writes" stub_writes() { : >"$WRITES" # shellcheck disable=SC2317 # invoked indirectly, by the forge verbs curl() { local hdr="" out="" method=GET url="" payload="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -X) method="$2"; shift ;; -d) payload="$2"; shift ;; -H) shift ;; -*) ;; *) url="$1" ;; esac shift done printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr" case "$url" in *"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;; *) printf '{}' >"$out" ;; esac [ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" return 0 } } # The label does not exist yet -> POST (create). FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue' check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES" # The label already exists -> PATCH (update), which is what --force does. FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text' check "recreating an existing label PATCHes it" 0 "" \ grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES" check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES" check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES" # --- forge_pr_view: newest verdict per context must win ------------------ # checks_state groups repeated contexts and selects the newest by # [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left # the winner to incidental array order, so a stale re-run could outrank the # live one (#4743). Forgejo's combined status carries created_at/updated_at # — measured on this instance, where Actions DO land as commit statuses # (rig main: "ci / check (push)" success, with created_at). pr_view_stub() { # shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view curl() { local hdr="" out="" url="" while [ $# -gt 0 ]; do case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac shift done printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr" case "$url" in */status) printf '%s' "$FAKE_STATUS" >"$out" ;; *) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;; esac return 0 } } # The FAILURE is older but listed second — array order would pick it. FAKE_STATUS='{"state":"failure","statuses":[ {"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}, {"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}' pr_view_stub view_json="$(REPO=o/r forge_pr_view 5)" check "pr_view maps createdAt" 0 "" \ grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json" check "pr_view maps completedAt" 0 "" \ grep -q '"completedAt":' <<<"$view_json" check "pr_view maps mergeable to the UI string" 0 "" \ grep -q '"mergeable": "MERGEABLE"' <<<"$view_json" # The real proof: feed it to the production classifier and confirm the newer # SUCCESS wins over the older FAILURE regardless of array order. # shellcheck source=actions/labels-reconcile/labels-reconcile.sh . "$ROOT/actions/labels-reconcile/labels-reconcile.sh" classified="$(checks_state <<<"$view_json")" check "the newest verdict per context wins, not the array order" 0 "" \ test "$classified" = SUCCESS # --- the api base must be known ----------------------------------------- check "no api base refuses" 1 "cannot reach the forge" \ bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base' summary