feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
#!/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'
|
2026-08-02 19:03:40 +00:00
|
|
|
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
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.
|
|
|
|
|
|
2026-08-02 19:07:32 +00:00
|
|
|
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
|
|
|
|
|
# successive page bodies, declaring <total-spec> 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.
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
fake_forge() {
|
|
|
|
|
FAKE_TOTAL="$1"; shift
|
|
|
|
|
FAKE_PAGES=("$@")
|
|
|
|
|
FAKE_CALLS=0
|
2026-08-02 19:03:40 +00:00
|
|
|
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
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
|
2026-08-02 19:07:32 +00:00
|
|
|
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
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
{
|
|
|
|
|
printf 'HTTP/1.1 200 OK\r\n'
|
2026-08-02 19:07:32 +00:00
|
|
|
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
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'
|
|
|
|
|
|
2026-08-02 19:07:32 +00:00
|
|
|
# @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'
|
2026-08-02 19:16:48 +00:00
|
|
|
# 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" \
|
2026-08-02 19:07:32 +00:00
|
|
|
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'
|
|
|
|
|
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
# --- 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}]'
|
2026-08-02 19:24:27 +00:00
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
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'
|
|
|
|
|
|
fix(forge): parity gaps in the forgejo verbs — upsert, timestamps, typos
@codex-reviewer-andresmgsl's three findings (#4743), all real.
1. forge_label_create is now an UPSERT, matching gh label create --force.
bootstrap_labels creates every declared label on EVERY workflow_dispatch,
so a plain POST onto an existing name aborted the bootstrap under set -e
from the second dispatch onward. Resolves name -> id and PATCHes when it
exists.
2. forge_pr_view carries createdAt/completedAt. 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 verdict. The combined status carries
created_at and updated_at — measured.
3. forge_issue_edit refuses unknown flags and missing values. The github
backend hands them to gh, which fails; dropping them here turned a
mis-typed port site into a mutation that silently did not happen — this
issue's own failure class, inside the fix for it.
Also settles @grok-reviewer-andresmgsl's note 3 (#4741): Forgejo Actions DO
land as commit statuses on this instance, so the rollup is not empty.
rig main carries four — "ci / check (push)" and siblings, state success,
each with created_at. statusCheckRollup therefore populates, and NONE is not
silently substituted for SUCCESS.
Each fix mutation-verified: dropping the timestamps, forcing POST-always, and
restoring the silent flag skip each red exactly their own cases. The
newest-verdict case drives the real checks_state, not a copy.
Refs #188
2026-08-02 19:22:28 +00:00
|
|
|
# --- 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"
|
|
|
|
|
|
test(forge): hermetic cases for the two forgejo edit asymmetries
The coverage owed with the call-site port (@grok-reviewer-andresmgsl #4741
note 2, #4751 item 2). Live scratch-repo evidence proved these work; these
pin the request SHAPE so they keep working.
- a removal resolves name -> numeric id, and never sends the name as the
path segment (measured: DELETE .../labels/probe:one -> 422,
DELETE .../labels/149 -> 204);
- a removal of a label the repo does not have writes nothing, matching gh:
the reconcilers call --remove-label unconditionally to converge state;
- adds take names directly, one request, comma-separated values split as
gh splits them;
- an assignee removal PATCHes the SURVIVING list, because Forgejo sets
assignees rather than adding and removing them — a naive translation
would have cleared every other assignee as a side effect of removing
one, which is what the mutation test proves is caught.
Payloads are now compact JSON. They were pretty-printed, which spread a
single write across several lines — harder to read in a log, and it hid the
shape from any assertion matching a line.
Refs #188
2026-08-02 19:50:37 +00:00
|
|
|
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
|
|
|
|
|
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
|
|
|
|
|
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
|
|
|
|
|
# they keep working, and pin the SHAPE of the requests.
|
|
|
|
|
|
|
|
|
|
# Removal resolves name -> id, because Forgejo takes names on add and only a
|
|
|
|
|
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
|
|
|
|
|
# DELETE .../labels/149 -> 204.
|
|
|
|
|
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
|
|
|
|
|
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
|
|
|
|
|
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
|
|
|
|
|
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
|
|
|
|
|
|
|
|
|
|
# A label the repo does not have is a no-op, matching gh: the reconcilers
|
|
|
|
|
# call --remove-label unconditionally to converge state.
|
|
|
|
|
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
|
|
|
|
|
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
|
|
|
|
|
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
|
|
|
|
|
|
|
|
|
|
# Adding takes names directly — no lookup, one request.
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
|
|
|
|
|
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
|
|
|
|
|
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
|
|
|
|
|
|
|
|
|
|
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
|
|
|
|
|
# removal is a read-modify-write, and a naive translation would have cleared
|
|
|
|
|
# every OTHER assignee as a side effect of removing one.
|
|
|
|
|
assignee_stub() {
|
|
|
|
|
: >"$WRITES"
|
|
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
|
|
|
|
|
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: 0\r\n\r\n' >"$hdr"
|
|
|
|
|
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
|
|
|
|
|
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
assignee_stub
|
|
|
|
|
REPO=o/r forge_issue_edit 5 --remove-assignee alice
|
|
|
|
|
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
|
|
|
|
|
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
|
|
|
|
|
|
|
|
|
|
assignee_stub
|
|
|
|
|
REPO=o/r forge_issue_edit 5 --add-assignee carol
|
|
|
|
|
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
|
|
|
|
|
|
fix: the four findings from the panel round on 2168e4e
@codex-reviewer-andresmgsl #4780, concurred by @grok-reviewer-andresmgsl
#4785. All four real.
1. Three issueflow call sites still named per_page=100. The backend
sanitized it so it worked, but the frozen term and the changelog both say
no call site names a page size — and a contract that holds only because
something downstream cleans up is not the contract. Endpoints now carry
their logical query alone.
2. The suite's summary and `[ "$fail" -eq 0 ]` gate sat in the MIDDLE of
test/labels-reconcile.test.sh, and the eight outstanding_requests expects
were appended after them. Proven before fixing: a deliberately broken
term-4 assertion printed FAIL, was excluded from the totals, and the
suite still exited 0. Those assertions were decorative. The gate moves to
the true end, with a note that nothing goes below it; the reported count
goes 157 -> 164, which is the eight that were never being counted.
3. forge_labels_add and forge_request_reviewer arrived with the port and had
no boundary pins. Both backends now have them, and the labels_add cases
pin the property ceremony#128 turns on: an additive POST, never a PUT of
the whole set, exactly one write so nothing is read-modify-written.
Mutation-verified — making it RMW/PUT, or routing github through
`issue edit --add-label`, each red their own cases.
4. The historical comment said the old gathers were `forge_api graphql`. My
own mechanical port rewrote it; before #188 they were `gh api graphql`
and the abstraction did not exist.
Refs #188
2026-08-02 19:58:51 +00:00
|
|
|
# --- forge_labels_add / forge_request_reviewer, both backends ------------
|
|
|
|
|
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
|
|
|
|
|
# call-site port and had no boundary pins of their own.
|
|
|
|
|
|
|
|
|
|
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
|
|
|
|
|
# The labeler action computed (labels-at-job-start union derived) and PUT the
|
|
|
|
|
# whole set, so a label applied while the job ran was silently removed —
|
|
|
|
|
# ceremony#128 lost its `release` label, the merge door's declared-intent
|
|
|
|
|
# read, two seconds after the builder set it. This write must therefore be an
|
|
|
|
|
# ADDITIVE POST and must never read-modify-write.
|
|
|
|
|
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
|
|
|
|
|
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
|
|
|
|
|
REPO=o/r forge_labels_add 7 scope:docs scope:cli
|
|
|
|
|
check "labels_add POSTs to the issue labels collection" 0 "" \
|
|
|
|
|
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
|
|
|
|
|
check "...carrying every name in one request" 0 "" \
|
|
|
|
|
grep -q '"scope:docs","scope:cli"' "$WRITES"
|
|
|
|
|
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
|
|
|
|
|
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
|
|
|
|
|
check "...exactly one write, so nothing is read-modify-written" 0 "" \
|
|
|
|
|
test "$(wc -l <"$WRITES")" -eq 1
|
|
|
|
|
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
|
|
|
|
|
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
|
|
|
|
|
|
|
|
|
|
# The reviewer payload shape. Measured against this instance: the endpoint
|
|
|
|
|
# serves post and delete only, and takes {"reviewers":[...]}.
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
|
|
|
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
|
|
|
|
|
check "request_reviewer POSTs to requested_reviewers" 0 "" \
|
|
|
|
|
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
|
|
|
|
|
check "...with the reviewers array payload" 0 "" \
|
|
|
|
|
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
|
|
|
|
|
|
|
|
|
|
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
|
|
|
|
|
# by the command it builds rather than by an HTTP shape.
|
|
|
|
|
gh_calls="$TMP/ghcalls"
|
|
|
|
|
: >"$gh_calls"
|
|
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
|
|
|
|
|
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
|
|
|
|
|
# A subshell so the github backend does not stay loaded over the forgejo
|
|
|
|
|
# cases below; REPO is deliberately scoped to it for the same reason.
|
|
|
|
|
(
|
|
|
|
|
forge_select github
|
|
|
|
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
|
|
|
|
REPO=o/r
|
|
|
|
|
forge_labels_add 7 scope:docs scope:cli
|
|
|
|
|
forge_request_reviewer 9 danmt
|
|
|
|
|
)
|
|
|
|
|
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
|
|
|
|
|
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
|
|
|
|
|
check "...and never routes through issue edit --add-label" 1 "" \
|
|
|
|
|
grep -q 'issue edit' "$gh_calls"
|
|
|
|
|
check "github request_reviewer posts the reviewer" 0 "" \
|
|
|
|
|
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
|
2026-08-03 15:26:08 +00:00
|
|
|
|
|
|
|
|
# --- term-5 pins for the batch verbs (codex 1566) -------------------------
|
|
|
|
|
# The forgejo twins have hermetic coverage below; these pin that the github
|
|
|
|
|
# twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites.
|
|
|
|
|
: >"$gh_calls"
|
|
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
|
|
|
|
|
gh() {
|
|
|
|
|
printf '%s\n' "$*" >>"$gh_calls"
|
|
|
|
|
if [ "$1" = api ]; then
|
|
|
|
|
shift
|
|
|
|
|
local jqexpr="" endpoint=""
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--jq) jqexpr="$2"; shift ;;
|
|
|
|
|
--paginate) ;;
|
|
|
|
|
-*) ;;
|
|
|
|
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
local body='[]'
|
|
|
|
|
case "$endpoint" in
|
|
|
|
|
*'/issues/'*'/timeline'*)
|
|
|
|
|
body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/issues/'*'/comments'*)
|
|
|
|
|
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/pulls/'*'/comments'*)
|
|
|
|
|
body='[{"created_at":"2026-08-01T10:30:00Z"}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/pulls/'*'/commits'*)
|
|
|
|
|
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
gh_tl="$(
|
|
|
|
|
forge_select github
|
|
|
|
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
|
|
|
|
REPO=o/r
|
|
|
|
|
forge_timeline 42
|
|
|
|
|
)"
|
|
|
|
|
check "github forge_timeline paginates the issue timeline endpoint" 0 "" \
|
|
|
|
|
grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls"
|
|
|
|
|
check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \
|
|
|
|
|
jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null
|
|
|
|
|
: >"$gh_calls"
|
|
|
|
|
gh_act="$(
|
|
|
|
|
forge_select github
|
|
|
|
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
|
|
|
|
REPO=o/r
|
|
|
|
|
forge_pr_activity 9 | sort
|
|
|
|
|
)"
|
|
|
|
|
check "github forge_pr_activity hits issue comments" 0 "" \
|
|
|
|
|
grep -q 'repos/o/r/issues/9/comments' "$gh_calls"
|
|
|
|
|
check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \
|
|
|
|
|
grep -q 'repos/o/r/pulls/9/comments' "$gh_calls"
|
|
|
|
|
check "github forge_pr_activity hits commits" 0 "" \
|
|
|
|
|
grep -q 'repos/o/r/pulls/9/commits' "$gh_calls"
|
|
|
|
|
check "github forge_pr_activity emits all three timestamp sources" 0 "" \
|
|
|
|
|
test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')"
|
|
|
|
|
|
fix: the four findings from the panel round on 2168e4e
@codex-reviewer-andresmgsl #4780, concurred by @grok-reviewer-andresmgsl
#4785. All four real.
1. Three issueflow call sites still named per_page=100. The backend
sanitized it so it worked, but the frozen term and the changelog both say
no call site names a page size — and a contract that holds only because
something downstream cleans up is not the contract. Endpoints now carry
their logical query alone.
2. The suite's summary and `[ "$fail" -eq 0 ]` gate sat in the MIDDLE of
test/labels-reconcile.test.sh, and the eight outstanding_requests expects
were appended after them. Proven before fixing: a deliberately broken
term-4 assertion printed FAIL, was excluded from the totals, and the
suite still exited 0. Those assertions were decorative. The gate moves to
the true end, with a note that nothing goes below it; the reported count
goes 157 -> 164, which is the eight that were never being counted.
3. forge_labels_add and forge_request_reviewer arrived with the port and had
no boundary pins. Both backends now have them, and the labels_add cases
pin the property ceremony#128 turns on: an additive POST, never a PUT of
the whole set, exactly one write so nothing is read-modify-written.
Mutation-verified — making it RMW/PUT, or routing github through
`issue edit --add-label`, each red their own cases.
4. The historical comment said the old gathers were `forge_api graphql`. My
own mechanical port rewrote it; before #188 they were `gh api graphql`
and the abstraction did not exist.
Refs #188
2026-08-02 19:58:51 +00:00
|
|
|
unset -f gh
|
|
|
|
|
. "$ROOT/lib/forge-forgejo.sh"
|
|
|
|
|
|
fix(forge): parity gaps in the forgejo verbs — upsert, timestamps, typos
@codex-reviewer-andresmgsl's three findings (#4743), all real.
1. forge_label_create is now an UPSERT, matching gh label create --force.
bootstrap_labels creates every declared label on EVERY workflow_dispatch,
so a plain POST onto an existing name aborted the bootstrap under set -e
from the second dispatch onward. Resolves name -> id and PATCHes when it
exists.
2. forge_pr_view carries createdAt/completedAt. 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 verdict. The combined status carries
created_at and updated_at — measured.
3. forge_issue_edit refuses unknown flags and missing values. The github
backend hands them to gh, which fails; dropping them here turned a
mis-typed port site into a mutation that silently did not happen — this
issue's own failure class, inside the fix for it.
Also settles @grok-reviewer-andresmgsl's note 3 (#4741): Forgejo Actions DO
land as commit statuses on this instance, so the rollup is not empty.
rig main carries four — "ci / check (push)" and siblings, state success,
each with created_at. statusCheckRollup therefore populates, and NONE is not
silently substituted for SUCCESS.
Each fix mutation-verified: dropping the timestamps, forcing POST-always, and
restoring the silent flag skip each red exactly their own cases. The
newest-verdict case drives the real checks_state, not a copy.
Refs #188
2026-08-02 19:22:28 +00:00
|
|
|
# --- 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
|
|
|
|
|
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
# --- 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'
|
|
|
|
|
|
2026-08-03 15:13:30 +00:00
|
|
|
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
|
|
|
|
|
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
|
|
|
|
|
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
|
|
|
|
|
# emitting .user instead of .actor each reds its own case (#4853).
|
|
|
|
|
timeline_stub() {
|
|
|
|
|
# shellcheck disable=SC2317 # 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
|
|
|
|
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
|
|
|
|
|
case "$url" in
|
|
|
|
|
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
|
|
|
|
*) printf '[]' >"$out" ;;
|
|
|
|
|
esac
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
FAKE_TIMELINE='[
|
|
|
|
|
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
|
|
|
|
|
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
|
|
|
|
|
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
|
|
|
|
|
]'
|
|
|
|
|
FAKE_TL_N=3
|
|
|
|
|
timeline_stub
|
|
|
|
|
tl="$(REPO=o/r forge_timeline 188)"
|
|
|
|
|
check "forge_timeline projects body=1 to labeled" 0 "" \
|
|
|
|
|
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
|
|
|
|
|
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
|
|
|
|
|
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
|
|
|
|
|
check "forge_timeline drops non-label events" 0 "" \
|
|
|
|
|
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
|
|
|
|
|
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
|
|
|
|
|
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
|
|
|
|
|
# Unreadable: curl fails. Status must surface through forge_timeline itself
|
|
|
|
|
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
|
|
|
|
|
# shellcheck disable=SC2317
|
|
|
|
|
curl() { return 22; }
|
|
|
|
|
tl_unreadable() { REPO=o/r forge_timeline 188; }
|
|
|
|
|
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
|
|
|
|
|
|
|
|
|
|
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
|
|
|
|
|
# Only reviews with comments_count > 0 are fetched (#4844).
|
|
|
|
|
activity_calls="$TMP/activity_calls"
|
|
|
|
|
: >"$activity_calls"
|
|
|
|
|
activity_stub() {
|
|
|
|
|
# shellcheck disable=SC2317
|
|
|
|
|
curl() {
|
|
|
|
|
local hdr="" out="" url="" total=1 body='[]'
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
printf '%s\n' "$url" >>"$activity_calls"
|
|
|
|
|
case "$url" in
|
|
|
|
|
*'/issues/'*'/comments'*)
|
|
|
|
|
total=1
|
|
|
|
|
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/pulls/'*'/commits'*)
|
|
|
|
|
total=1
|
|
|
|
|
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/reviews/'*'/comments'*)
|
|
|
|
|
total=1
|
|
|
|
|
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
|
|
|
|
|
;;
|
|
|
|
|
*'/pulls/'*'/reviews'*)
|
|
|
|
|
total=2
|
|
|
|
|
body="$FAKE_REVIEWS"
|
|
|
|
|
;;
|
|
|
|
|
*) total=0; body='[]' ;;
|
|
|
|
|
esac
|
|
|
|
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
|
|
|
|
|
printf '%s' "$body" >"$out"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
|
|
|
|
|
activity_stub
|
|
|
|
|
: >"$activity_calls"
|
|
|
|
|
act="$(REPO=o/r forge_pr_activity 9 | sort)"
|
|
|
|
|
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
|
|
|
|
|
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
|
|
|
|
|
check "forge_pr_activity emits commit timestamps" 0 "" \
|
|
|
|
|
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
|
|
|
|
|
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
|
|
|
|
|
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
|
|
|
|
|
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
|
|
|
|
|
grep -q '/reviews/7/comments' "$activity_calls"
|
|
|
|
|
check "...and never fetches a zero-comment review" 1 "" \
|
|
|
|
|
grep -q '/reviews/8/comments' "$activity_calls"
|
|
|
|
|
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
|
|
|
|
|
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
|
|
|
|
|
|
feat(forge): two backends behind one call surface, and the shim owns paging
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.
The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:
?per_page=100 GitHub 100 items Forgejo 30 items (ignored)
?limit=100 GitHub 30 items Forgejo 50 items (capped)
Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.
Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.
@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.
Call sites are not ported yet; that is the next commit.
Refs #188
2026-08-02 19:00:58 +00:00
|
|
|
summary
|