fix: exhaust Forgejo timeline pagination

This commit is contained in:
codex-bot-andresmgsl 2026-08-24 17:53:13 +00:00
parent 8c0f5d53d7
commit 40ebcea462
3 changed files with 53 additions and 18 deletions

3
changelog.d/240.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).

View file

@ -84,7 +84,7 @@ forgejo_page_url() {
esac esac
} }
# forge_api [--paginate] <endpoint> [--jq <expr>] # forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
# #
# --paginate walks page= until a short page, then PROVES the walk was # --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared # complete by comparing what it collected against the server's declared
@ -94,11 +94,21 @@ forgejo_page_url() {
# make the completeness check compare null to a number — the guard itself # make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the # degrading silently, which is the failure class re-entering through the
# door built to stop it. # door built to stop it.
#
# --paginate-exhaustive is the narrow alternative for an endpoint whose
# x-total-count is known not to describe the collection. It proves completion
# by reading through the first short page and never consults that header.
forge_api() { forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--paginate) paginate=true ;; --paginate) paginate=true ;;
--paginate-exhaustive) paginate_exhaustive=true ;;
-X | --method)
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
method="$2"
shift
;;
--jq) jqexpr="$2"; have_jq=true; shift ;; --jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;; -*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;; *) [ -n "$endpoint" ] || endpoint="$1" ;;
@ -106,6 +116,14 @@ forge_api() {
shift shift
done done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; } [ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
return 1
fi
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
echo "forge_api: pagination is available only for GET requests" >&2
return 1
fi
local base token local base token
base="$(forgejo_api_base)" || return 1 base="$(forgejo_api_base)" || return 1
@ -116,7 +134,7 @@ forge_api() {
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose # shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ]; then if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \ if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \ -H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then "$base/$endpoint"; then
@ -142,21 +160,23 @@ forge_api() {
fi fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1 forgejo_http_ok "$hdr" "GET $endpoint" || return 1
# Re-read on EVERY page, not once (#4712). A board that changes size if [ "$paginate_exhaustive" = false ]; then
# under the walk was invisible: page 1 declaring 4 and page 2 declaring # Re-read on EVERY page, not once (#4712). A board that changes size
# 9 stopped at 4 believing itself whole. A moving total means the read # under the walk was invisible: page 1 declaring 4 and page 2 declaring
# cannot have been atomic, so it is refused rather than reconciled. # 9 stopped at 4 believing itself whole. A moving total means the read
local page_total # cannot have been atomic, so it is refused rather than reconciled.
page_total="$(forgejo_total_count "$hdr")" || return 1 local page_total
if [ -z "$total" ]; then page_total="$(forgejo_total_count "$hdr")" || return 1
total="$page_total" if [ -z "$total" ]; then
elif [ "$page_total" != "$total" ]; then total="$page_total"
cat >&2 <<EOF elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188). forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
The collection moved under the walk, so no page set can be proven whole. The collection moved under the walk, so no page set can be proven whole.
Refusing rather than reconciling a board that is already out of date. Refusing rather than reconciling a board that is already out of date.
EOF EOF
return 1 return 1
fi
fi fi
pagejson="$(cat "$body")" pagejson="$(cat "$body")"
@ -175,13 +195,17 @@ EOF
[ "$n" -gt 0 ] || break [ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")" all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n)) got=$((got + n))
[ "$got" -lt "$total" ] || break if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break
fi
page=$((page + 1)) page=$((page + 1))
done done
# The assert. A short read here is the silent-truncation bug arriving by # The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning. # another route, so it is fatal rather than a warning.
if [ "$got" -ne "$total" ]; then if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
cat >&2 <<EOF cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188). forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the Refusing rather than reconciling a partial board: a sweep over part of the
@ -561,7 +585,13 @@ forge_request_reviewer() {
# empty timeline — the two states the ruling ladder must tell apart (#4853). # empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() { forge_timeline() {
local n="${1:?forge_timeline: number required}" raw local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1 # Measured on this instance: limit=10 reports x-total-count=10 and limit=50
# reports 50, while crew!96 held 151 events and strict pagination returned
# only its first 50. No other measured endpoint echoes its page size this
# way. Timelines are append-only, so exhaustion can include concurrent new
# events but cannot create a deletion hole; that is why only this call site
# may bypass the header-bound completeness proof (#240).
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
jq ' jq '
[.[] [.[]
| select(.type == "label") | select(.type == "label")

View file

@ -848,7 +848,9 @@ FAKE_TL_HEADERS=yes
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \ check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline' forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses a non-GET method" 1 "GET" \ check "exhaustive pagination refuses a non-GET method" 1 "GET" \
forge_api --paginate-exhaustive --method POST 'repos/o/r/issues/188/timeline' forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
check "the exhaustive flag has exactly one production call site" 0 "" \
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
# Unreadable: curl fails. Status must surface through forge_timeline itself # 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. # (not a later jq), or the ruling ladder invents a verdict on a half-read.