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"
|
|
|
|
|
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
# Every /api/v1 call ceremony makes is repo-scoped, and since #191 the
|
|
|
|
|
# backend refuses to build `repos//…` — so the suite names a repo up front,
|
|
|
|
|
# the way every real caller does.
|
|
|
|
|
REPO=o/r
|
|
|
|
|
export REPO
|
|
|
|
|
|
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 "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
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
# FAKE_FAIL_URL + FAKE_HTTP fault-inject one endpoint, so the refusal
|
|
|
|
|
# boundaries are driven rather than assumed (#192 review).
|
|
|
|
|
if [ -n "${FAKE_FAIL_URL:-}" ] && [ "${url##*"$FAKE_FAIL_URL"}" != "$url" ]; then
|
|
|
|
|
printf 'HTTP/1.1 %s Server Error\r\n\r\n' "${FAKE_HTTP:-500}" >"$hdr"
|
|
|
|
|
printf '{}' >"$out"
|
|
|
|
|
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
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
|
|
|
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" ;;
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
# The issue itself: the removal path reads its CURRENT label set before
|
|
|
|
|
# computing the set to PUT (#192).
|
|
|
|
|
*/issues/[0-9]*) printf '{"labels": %s}' "${FAKE_ISSUE_LABELS:-[]}" >"$out" ;;
|
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
|
|
|
*) 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.
|
|
|
|
|
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
# Removal is a FULL-SET PUT, not a per-label DELETE (#192). Measured under a
|
|
|
|
|
# real Actions token, probe run 701: DELETE .../labels/{id} -> 500 for every
|
|
|
|
|
# removal, PUT .../labels -> 200 including the empty set. A PAT gets 204 on the
|
|
|
|
|
# same DELETE, which is why it went unseen — it fails only for the identity the
|
|
|
|
|
# sweep holds.
|
|
|
|
|
ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13},{"name":"attention","id":14}]'
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
check "removing a label PUTs the whole wanted set" 0 "" \
|
|
|
|
|
grep -q '^PUT repos/o/r/issues/5/labels ' "$WRITES"
|
|
|
|
|
check "...and never DELETEs, which this instance answers 500" 1 "" \
|
|
|
|
|
grep -q '^DELETE ' "$WRITES"
|
|
|
|
|
check "...carrying the surviving label's id and not the removed one" 0 '{"labels":[13]}' \
|
|
|
|
|
cat "$WRITES"
|
|
|
|
|
|
|
|
|
|
# The contract @codex-reviewer-andresmgsl asked for (#5183): a full-set PUT
|
|
|
|
|
# replaces everything, so removal alone proves nothing about PRESERVATION. One
|
|
|
|
|
# call, a combined delta, and two bystanders that must survive it.
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13},{"name":"attention","id":14}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label state:new
|
|
|
|
|
check "a combined add+remove is ONE write" 0 "" test "$(wc -l <"$WRITES")" -eq 1
|
|
|
|
|
preserves_bystanders() { # the PUT keeps state:new(12), scope:labels(13), attention(14)
|
|
|
|
|
grep -q 12 "$WRITES" && grep -q 13 "$WRITES" && grep -q 14 "$WRITES"
|
|
|
|
|
}
|
|
|
|
|
check "...and preserves every unrelated label" 0 "" preserves_bystanders
|
|
|
|
|
check "...while dropping only what was asked for" 1 "" grep -qE '(^|[^0-9])11([^0-9]|$)' "$WRITES"
|
|
|
|
|
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
# A label the issue does not carry is a successful no-op that writes NOTHING,
|
|
|
|
|
# matching gh: the reconcilers call --remove-label unconditionally to converge
|
|
|
|
|
# state, so most calls here ask to remove something absent. Writing the
|
|
|
|
|
# unchanged set back would open ceremony#128's read-modify-write window for no
|
|
|
|
|
# state change at all, and the GET above is already the proof the sweep reached
|
|
|
|
|
# the forge (@codex-reviewer-andresmgsl, #192 review).
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
check "removing an absent label succeeds" 0 "" test "$?" -eq 0
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
check "...writing nothing at all" 0 "" test ! -s "$WRITES"
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
|
|
|
|
|
# A full clear is the empty set, which this instance answers 200 (run 701).
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
check "clearing the last label PUTs the empty set" 0 '{"labels":[]}' cat "$WRITES"
|
|
|
|
|
|
|
|
|
|
# An add-label the repo does not have must refuse BEFORE any write: a PUT that
|
|
|
|
|
# silently dropped an unresolvable name would remove a label nobody asked to
|
|
|
|
|
# remove — a destructive write dressed as a partial success.
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
|
|
|
|
edit_unknown_add() {
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"state:old","id":11}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old --add-label no-such-label
|
|
|
|
|
}
|
|
|
|
|
check "an unknown add-label refuses" 1 "no label id" edit_unknown_add
|
|
|
|
|
check "...before writing anything" 0 "" test ! -s "$WRITES"
|
|
|
|
|
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
# The preserved-id contract, and the reason it is not merely an optimisation
|
|
|
|
|
# (@codex-reviewer-andresmgsl, #192 review): a bystander's id comes from the
|
|
|
|
|
# ISSUE payload, so preservation must not depend on a repository-wide list
|
|
|
|
|
# that has nothing to do with this issue. Here `attention` is on the issue with
|
|
|
|
|
# id 14 and is ABSENT from the repo-list fixture entirely — a resolution that
|
|
|
|
|
# went through forgejo_label_ids would refuse or drop it.
|
|
|
|
|
PARTIAL_ROSTER='[{"name":"state:old","id":11},{"name":"state:new","id":12},{"name":"scope:labels","id":13}]'
|
|
|
|
|
FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 stub_writes
|
|
|
|
|
FAKE_LABELS="$PARTIAL_ROSTER" FAKE_LABEL_N=3 \
|
|
|
|
|
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"attention","id":14}]' \
|
|
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
check "a bystander absent from the repo list is still preserved by its issue id" 0 \
|
|
|
|
|
'{"labels":[14]}' cat "$WRITES"
|
|
|
|
|
|
|
|
|
|
# The two fault boundaries the acceptance plan names. Both must be non-zero
|
|
|
|
|
# with the backend's own diagnostic, and neither may report success.
|
|
|
|
|
fail_get() {
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5' \
|
|
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
}
|
|
|
|
|
check "a failed current-label GET refuses, non-zero" 1 "" fail_get
|
fix(forgejo): a read failure names its verb too, and the tests assert the whole diagnostic (#192)
@codex-reviewer-andresmgsl caught a test that describes evidence it does not
collect — mine, and it is the class this PR is about.
Two cases were titled "naming the verb, path and status" and asserted only the
substring "500". The PUT boundary happened to satisfy the contract because
forgejo_write already passes "PUT $endpoint" to forgejo_http_ok. The GET
boundary did not: the diagnostic was `HTTP 500 from 'repos/o/r/issues/5'`, with
no verb at all — so a caller could not tell a failed READ from a failed WRITE
of the same path, and #192's acceptance criterion asks for exactly that
distinction.
Reads now pass "GET $endpoint" on both non-paginated and paginated paths, and
the two tests assert the complete expected diagnostic as one substring rather
than a status code that any failure would contain. Reverting the verb reds the
GET case.
Also, per the same review: the failed GET is asserted to write nothing, and the
failed PUT to have attempted exactly one write.
forge-backends 117/117 (was 115), test/run.sh 22/22 under jq 1.7 and jq 1.6,
shellcheck 0.10.0 and actionlint clean.
Refs #192
2026-08-05 13:11:33 +00:00
|
|
|
check "...naming the verb, the path AND the status, in one diagnostic" 1 \
|
|
|
|
|
"HTTP 500 from 'GET repos/o/r/issues/5'" fail_get
|
|
|
|
|
get_write_count() { : >"$WRITES"; fail_get >/dev/null 2>&1; wc -l <"$WRITES"; }
|
|
|
|
|
check "...having written nothing: the read failed before any mutation" 0 "0" \
|
|
|
|
|
get_write_count
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
fail_put() {
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 \
|
|
|
|
|
FAKE_ISSUE_LABELS='[{"name":"state:old","id":11},{"name":"scope:labels","id":13}]' \
|
|
|
|
|
FAKE_HTTP=500 FAKE_FAIL_URL='/issues/5/labels' \
|
|
|
|
|
REPO=o/r forge_issue_edit 5 --remove-label state:old
|
|
|
|
|
}
|
|
|
|
|
check "a failed replacement PUT refuses, non-zero" 1 "" fail_put
|
fix(forgejo): a read failure names its verb too, and the tests assert the whole diagnostic (#192)
@codex-reviewer-andresmgsl caught a test that describes evidence it does not
collect — mine, and it is the class this PR is about.
Two cases were titled "naming the verb, path and status" and asserted only the
substring "500". The PUT boundary happened to satisfy the contract because
forgejo_write already passes "PUT $endpoint" to forgejo_http_ok. The GET
boundary did not: the diagnostic was `HTTP 500 from 'repos/o/r/issues/5'`, with
no verb at all — so a caller could not tell a failed READ from a failed WRITE
of the same path, and #192's acceptance criterion asks for exactly that
distinction.
Reads now pass "GET $endpoint" on both non-paginated and paginated paths, and
the two tests assert the complete expected diagnostic as one substring rather
than a status code that any failure would contain. Reverting the verb reds the
GET case.
Also, per the same review: the failed GET is asserted to write nothing, and the
failed PUT to have attempted exactly one write.
forge-backends 117/117 (was 115), test/run.sh 22/22 under jq 1.7 and jq 1.6,
shellcheck 0.10.0 and actionlint clean.
Refs #192
2026-08-05 13:11:33 +00:00
|
|
|
check "...naming the verb, the path AND the status, in one diagnostic" 1 \
|
|
|
|
|
"HTTP 500 from 'PUT repos/o/r/issues/5/labels'" fail_put
|
|
|
|
|
put_write_count() { : >"$WRITES"; fail_put >/dev/null 2>&1; wc -l <"$WRITES"; }
|
|
|
|
|
check "...having attempted only the one PUT" 0 "1" put_write_count
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
# An ADD-ONLY call keeps the additive POST (ceremony#128): a read-modify-write
|
|
|
|
|
# there clobbered a label set two seconds after a builder wrote it.
|
|
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 stub_writes
|
fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
@codex-reviewer-andresmgsl's four gaps, all real, all taken.
1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
and then re-resolved every preserved label through the repository-wide list
— so preservation depended on a paginated read with nothing to do with this
issue, and an incomplete one would drop a bystander. It now keeps
name<TAB>id from the issue payload, subtracts removals by name, and resolves
ONLY added names. Fixture: a bystander on the issue with id 14 that is
absent from the repo-list fixture entirely must still survive the PUT.
2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
the write proved the sweep reached the forge. The GET already proves that,
and replacing a set with itself opens ceremony#128's window for no state
change — most calls here are exactly this case, since the reconcilers call
--remove-label unconditionally. Short-circuits when the wanted set equals
the current one. This was the policy-shaped choice flagged for @andres; the
reviewer's reasoning is better than mine was.
3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
state edit, so clearing `merge-next` and both `stale` edits could fail into
the generic per-PR branch and still finish `reconciled.` and exit 0. All
four sites go through one `label_write` helper, so a future call site cannot
reopen it by forgetting to mark itself. Probe: a failed NON-primary write
(unstale on a blocked PR) must fail the sweep.
4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
"reconciled.", which a log-tail consumer greps for. The line is now
"sweep incomplete", and the test asserts the whole output is free of the
token rather than only of the success prefix.
Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.
Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.
forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.
Refs #192
2026-08-05 13:03:25 +00:00
|
|
|
FAKE_LABELS="$ROSTER" FAKE_LABEL_N=4 FAKE_ISSUE_LABELS='[{"name":"scope:labels","id":13}]' \
|
fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
Two defects, one cause, and the second is why the first survived a week.
THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.
Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.
So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.
THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.
A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`
The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.
Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.
test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.
Refs #192
2026-08-05 12:48:09 +00:00
|
|
|
REPO=o/r forge_issue_edit 5 --add-label state:new
|
|
|
|
|
check "an add-only edit still POSTs additively" 0 "" \
|
|
|
|
|
grep -q '^POST repos/o/r/issues/5/labels ' "$WRITES"
|
|
|
|
|
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$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
|
|
|
|
|
|
|
|
# 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
|
2026-08-03 15:30:28 +00:00
|
|
|
# The NEGATIVE half of "pass-through". A positive pin still passes if the
|
|
|
|
|
# github path GAINS forgejo behaviour, and term 5 is a statement about what
|
|
|
|
|
# must NOT change — so the drift these two catch is the only kind a
|
|
|
|
|
# 1:1-extraction path realistically suffers.
|
|
|
|
|
#
|
|
|
|
|
# The GitHub timeline already IS the shape ruling.sh selects on, so a
|
|
|
|
|
# projection here would be a second, divergent normalizer maintained by
|
|
|
|
|
# nobody.
|
|
|
|
|
check "...and never reshapes it, as the forgejo backend must" 1 "" \
|
|
|
|
|
grep -q 'issues/42/timeline.*--jq' "$gh_calls"
|
2026-08-03 15:26:08 +00:00
|
|
|
: >"$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')"
|
2026-08-03 15:30:28 +00:00
|
|
|
# The forgejo backend derives inline comments from reviews with
|
|
|
|
|
# comments_count > 0, because the flat endpoint 404s there. That derivation
|
|
|
|
|
# must never appear on this path: GitHub serves the flat endpoint, and a
|
|
|
|
|
# github twin quietly adopting the workaround is the exact "both backends
|
|
|
|
|
# drift together" failure term 5 forbids.
|
|
|
|
|
check "...never deriving them from reviews, as forgejo must" 1 "" \
|
|
|
|
|
grep -q 'pulls/[0-9]*/reviews' "$gh_calls"
|
2026-08-03 15:26:08 +00:00
|
|
|
|
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"
|
|
|
|
|
|
2026-08-24 12:38:03 +00:00
|
|
|
# --- forge_pr_review_requests: the forge's live request set -------------
|
|
|
|
|
# A stale verdict is not itself evidence that anybody was re-requested. The
|
|
|
|
|
# backend reads the forge's request representation directly, so the state
|
|
|
|
|
# machine can distinguish the builder's ball from the panel's (#238).
|
|
|
|
|
review_requests_stub() {
|
|
|
|
|
fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# crew!96 after the builder pushed: two approvals and one requested change
|
|
|
|
|
# belong to the older head, but no live REQUEST_REVIEW row exists.
|
|
|
|
|
FAKE_REVIEWS_N=3
|
|
|
|
|
FAKE_REVIEWS='[
|
|
|
|
|
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"},
|
|
|
|
|
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"},
|
|
|
|
|
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}
|
|
|
|
|
]'
|
|
|
|
|
review_requests_stub
|
2026-08-24 12:46:42 +00:00
|
|
|
check "supersedes 'a stale approval still owes a verdict': one never re-requested is not requested" 0 "" \
|
2026-08-24 12:38:03 +00:00
|
|
|
eq "" forge_pr_review_requests 96
|
2026-08-24 14:42:36 +00:00
|
|
|
|
|
|
|
|
# rig!146 after merge: all three panelists submitted, so the reviews endpoint
|
|
|
|
|
# carries no REQUEST_REVIEW row even though requested_reviewers stayed stale.
|
|
|
|
|
FAKE_REVIEWS_N=3
|
|
|
|
|
FAKE_REVIEWS='[
|
|
|
|
|
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:05:01Z"},
|
|
|
|
|
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:14:45Z"},
|
|
|
|
|
{"user":{"login":"claude-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:03:39Z"}
|
|
|
|
|
]'
|
|
|
|
|
review_requests_stub
|
2026-08-24 12:46:42 +00:00
|
|
|
check "supersedes 'the never-cleared forgejo field collapses to who actually owes': a merged PR yields nobody" 0 "" \
|
2026-08-24 12:38:03 +00:00
|
|
|
eq "" forge_pr_review_requests 146
|
|
|
|
|
|
|
|
|
|
# crew!97's opening request rows. Deliberately unordered with one duplicate:
|
|
|
|
|
# the public contract is sorted unique logins, independent of API row order.
|
|
|
|
|
FAKE_REVIEWS_N=5
|
|
|
|
|
FAKE_REVIEWS='[
|
|
|
|
|
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
|
|
|
|
|
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
|
|
|
|
|
{"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
|
|
|
|
|
{"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
|
|
|
|
|
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"}
|
|
|
|
|
]'
|
|
|
|
|
review_requests_stub
|
|
|
|
|
check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \
|
|
|
|
|
eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97
|
|
|
|
|
|
|
|
|
|
FAKE_REVIEWS_N=2
|
|
|
|
|
FAKE_REVIEWS='[
|
|
|
|
|
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"},
|
|
|
|
|
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"}
|
|
|
|
|
]'
|
|
|
|
|
review_requests_stub
|
|
|
|
|
check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \
|
|
|
|
|
eq "claude-bot" forge_pr_review_requests 97
|
|
|
|
|
|
|
|
|
|
FAKE_REVIEWS_N=1
|
|
|
|
|
FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]'
|
|
|
|
|
review_requests_stub
|
2026-08-24 12:46:42 +00:00
|
|
|
check "supersedes 'a Forgejo request row is not an answer and leaves the login outstanding': its login is requested" 0 "" \
|
2026-08-24 12:38:03 +00:00
|
|
|
eq "kimi-bot" forge_pr_review_requests 97
|
|
|
|
|
|
|
|
|
|
# GitHub already exposes the exact live set on the PR object. Stub only the
|
|
|
|
|
# network boundary and assert the same backend-neutral output contract.
|
|
|
|
|
. "$ROOT/lib/forge-github.sh"
|
|
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
|
|
|
|
gh() {
|
2026-08-24 14:42:36 +00:00
|
|
|
[ "$1" = api ] || return 1
|
|
|
|
|
shift
|
|
|
|
|
local jqexpr="" endpoint=""
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--jq) jqexpr="$2"; shift ;;
|
|
|
|
|
-*) ;;
|
|
|
|
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
[ "$endpoint" = repos/o/r/pulls/55 ] || return 1
|
|
|
|
|
jq -r "$jqexpr" <<'JSON'
|
2026-08-24 12:38:03 +00:00
|
|
|
{"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]}
|
|
|
|
|
JSON
|
|
|
|
|
}
|
|
|
|
|
check "github returns requested_reviewers from the PR object unchanged" 0 "" \
|
|
|
|
|
eq $'claude-bot\nglm-bot' forge_pr_review_requests 55
|
|
|
|
|
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" ;;
|
2026-08-23 17:40:06 +00:00
|
|
|
*) printf '%s' "$FAKE_PR" >"$out" ;;
|
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
|
|
|
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"}]}'
|
2026-08-23 17:40:06 +00:00
|
|
|
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
|
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
|
|
|
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"
|
2026-08-23 17:40:06 +00:00
|
|
|
|
|
|
|
|
# Forgejo folds conflict-checking, conflict-check errors, real conflicts, and
|
|
|
|
|
# WIP into one false boolean. These fixtures pin the distinctions the API
|
|
|
|
|
# object still lets the backend report honestly (#236).
|
|
|
|
|
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":false}'
|
|
|
|
|
view_json="$(REPO=o/r forge_pr_view 5)"
|
|
|
|
|
check "pr_view reports draft mergeability as unknown" 0 "" \
|
|
|
|
|
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
|
|
|
|
|
|
|
|
|
|
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"base","draft":false,"mergeable":false}'
|
|
|
|
|
view_json="$(REPO=o/r forge_pr_view 5)"
|
|
|
|
|
check "pr_view reports a fast-forward false mergeability as unknown" 0 "" \
|
|
|
|
|
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
|
|
|
|
|
|
|
|
|
|
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":false}'
|
|
|
|
|
view_json="$(REPO=o/r forge_pr_view 5)"
|
|
|
|
|
check "pr_view preserves a distinguishable real conflict" 0 "" \
|
|
|
|
|
grep -q '"mergeable": "CONFLICTING"' <<<"$view_json"
|
|
|
|
|
|
|
|
|
|
# Unreachable on Forgejo: Mergeable() includes !IsWorkInProgress(). This
|
|
|
|
|
# ordering fixture exists only to pin draft ahead of mergeable true (#236).
|
|
|
|
|
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":true}'
|
|
|
|
|
view_json="$(REPO=o/r forge_pr_view 5)"
|
|
|
|
|
check "pr_view reads draft before the mergeable boolean" 0 "" \
|
|
|
|
|
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
|
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
|
|
|
# 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"
|
|
|
|
|
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
# --- the release door's verbs, both backends (#191) -----------------------
|
|
|
|
|
# The five verbs the release path now goes through. These carry two measured
|
|
|
|
|
# asymmetries that would 404 forever if wrong, and neither is visible to a
|
|
|
|
|
# github-only suite:
|
|
|
|
|
#
|
|
|
|
|
# PRs behind a commit GitHub GET /commits/{sha}/pulls -> ARRAY
|
|
|
|
|
# Forgejo GET /commits/{sha}/pull -> ONE OBJECT
|
|
|
|
|
# (the plural 404s)
|
|
|
|
|
# tag creation GitHub POST /git/refs
|
|
|
|
|
# Forgejo POST /tags (/git/refs is GET-only)
|
|
|
|
|
|
|
|
|
|
# release_stub <code> <body> — a curl stub answering one canned response and
|
|
|
|
|
# recording the method+path it was asked for.
|
|
|
|
|
release_stub() {
|
|
|
|
|
# Globals, not locals: the curl closure below runs long after this
|
|
|
|
|
# function returns, exactly as stub_writes does above.
|
|
|
|
|
STUB_CODE="$1" STUB_BODY="$2"
|
|
|
|
|
: >"$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 ;;
|
fix(forge): percent-encode asset names, and stop the docs naming a client
Both findings are @codex's on !193 (#1583), and both are real.
The asset name travels as a QUERY VALUE, and the artifact-hook contract
permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw
interpolation meant `release asset.tgz` made curl reject the URL outright
(exit 3), and '&', '#', '+', '%' silently changed the name or the query's
shape. `gh release create` handled all of those, so a 1:1 port had to.
Encoded through one boundary — jq's @uri, since jq is already a hard
dependency of this backend and a hand-rolled sed class is how the next
unescaped character gets through. Six backend cases cover it: the encoder
on a space and on the delimiters, uploads under both names, the created
release id in the path, and the multipart attachment. Mutation-checked:
dropping the encoder fails exactly the two name assertions.
docs/CONSUMERS.md's artifact-hook recovery still told operators to "run
`gh release create` by hand" and described the hook as running "before
`gh release create`" — on a Forgejo runner that is precisely the failure
this PR fixes. It now names the forge-neutral tag-door recovery first and
shows both clients for the manual path, without regressing the GitHub
guidance.
1035 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:11:06 +00:00
|
|
|
-F) payload="$payload -F $2"; shift ;;
|
|
|
|
|
-H) shift ;;
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
-*) ;;
|
|
|
|
|
*) url="$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr"
|
|
|
|
|
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out"
|
|
|
|
|
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GITHUB_API_URL=https://forge.example/api/v1
|
|
|
|
|
export GITHUB_API_URL
|
|
|
|
|
|
|
|
|
|
# Helpers so the assertions run in THIS shell, where the verbs are defined.
|
|
|
|
|
pulls_is_array() { forge_commit_pulls "$1" | jq -e 'type == "array" and length == 1' >/dev/null && echo array-of-1; }
|
|
|
|
|
writes_after() { "$@" >/dev/null 2>&1; cat "$WRITES"; }
|
|
|
|
|
repo_empty_release() { REPO='' forge_release_exists 1.2.3; }
|
|
|
|
|
repo_empty_pulls() { REPO='' forge_commit_pulls deadbeef; }
|
|
|
|
|
|
2026-08-05 15:01:32 +00:00
|
|
|
# forge_commit_at — the FOURTH asymmetry (#209). Forgejo 404s on /commits/{sha}
|
|
|
|
|
# and serves the object at /git/commits/{sha}, with the committer date under
|
|
|
|
|
# `.created` rather than `.commit.committer.date`. A stubbed forge_api cannot
|
|
|
|
|
# catch a wrong PATH, which is how #198 shipped GitHub's path here and every
|
|
|
|
|
# sweep printed `could not read the head commit's date`.
|
|
|
|
|
release_stub 200 '{"created":"2026-08-05T13:11:33Z","commit":{"committer":{"date":"WRONG"}}}'
|
|
|
|
|
check "forgejo: the commit date comes from .created" 0 "2026-08-05T13:11:33Z" \
|
|
|
|
|
forge_commit_at deadbeef
|
|
|
|
|
fj_not_nested() { ! forge_commit_at deadbeef | grep -q WRONG; }
|
|
|
|
|
check "...and never from GitHub's nested field" 0 "" fj_not_nested
|
|
|
|
|
check "forgejo: it asks /git/commits/{sha}" 0 "git/commits/deadbeef" \
|
|
|
|
|
writes_after forge_commit_at deadbeef
|
|
|
|
|
fj_not_bare_path() { ! grep -qE 'repos/o/r/commits/deadbeef( |$)' "$WRITES"; }
|
|
|
|
|
check "...and never the bare /commits/{sha}, which 404s here" 0 "" fj_not_bare_path
|
|
|
|
|
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
release_stub 200 '{"number":7,"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}'
|
|
|
|
|
check "forgejo: one PR object becomes a one-element array" 0 '"number":7' \
|
|
|
|
|
forge_commit_pulls deadbeef
|
|
|
|
|
check "forgejo: the array is what the call site's jq expects" 0 "array-of-1" \
|
|
|
|
|
pulls_is_array deadbeef
|
|
|
|
|
check "forgejo: it reads the SINGULAR path" 0 "commits/deadbeef/pull " \
|
|
|
|
|
writes_after forge_commit_pulls deadbeef
|
|
|
|
|
|
|
|
|
|
release_stub 404 '{"message":"pull request does not exist"}'
|
|
|
|
|
check "forgejo: 404 is an empty array, not a failure" 0 "[]" forge_commit_pulls deadbeef
|
|
|
|
|
|
|
|
|
|
release_stub 500 '{}'
|
|
|
|
|
check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknown, not 'none'" \
|
|
|
|
|
forge_commit_pulls deadbeef
|
|
|
|
|
|
|
|
|
|
release_stub 200 '{"tag_name":"1.2.3"}'
|
|
|
|
|
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
|
|
|
|
|
release_stub 404 '{}'
|
|
|
|
|
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
|
|
|
|
|
release_stub 503 '{}'
|
|
|
|
|
check "forgejo: an unreadable release refuses, not 'no'" 1 "the answer is unknown, not 'no'" \
|
|
|
|
|
forge_release_exists 1.2.3
|
|
|
|
|
|
|
|
|
|
# THE MUST-FAIL (#191, found by @kimi on !193 before it shipped): with REPO
|
|
|
|
|
# empty every path becomes repos//… , whose 404 would read as a fact — "no"
|
|
|
|
|
# and "[]" with rc 0. That is the bug this issue exists to remove.
|
|
|
|
|
release_stub 404 '{}'
|
|
|
|
|
check "REPO empty refuses instead of fabricating 'no'" 1 "refusing to address 'repos//" \
|
|
|
|
|
repo_empty_release
|
|
|
|
|
check "REPO empty refuses instead of fabricating '[]'" 1 "refusing to address 'repos//" \
|
|
|
|
|
repo_empty_pulls
|
|
|
|
|
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "forgejo: a tag is created at /tags, not /git/refs" 0 "POST repos/o/r/tags" \
|
|
|
|
|
writes_after forge_tag_create 1.2.3 cafebabe
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
|
|
|
|
|
writes_after forge_tag_create 1.2.3 cafebabe
|
|
|
|
|
|
|
|
|
|
printf 'notes body\n' >"$TMP/notes.md"
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
|
|
|
|
|
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
|
|
|
|
|
|
fix(forge): percent-encode asset names, and stop the docs naming a client
Both findings are @codex's on !193 (#1583), and both are real.
The asset name travels as a QUERY VALUE, and the artifact-hook contract
permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw
interpolation meant `release asset.tgz` made curl reject the URL outright
(exit 3), and '&', '#', '+', '%' silently changed the name or the query's
shape. `gh release create` handled all of those, so a 1:1 port had to.
Encoded through one boundary — jq's @uri, since jq is already a hard
dependency of this backend and a hand-rolled sed class is how the next
unescaped character gets through. Six backend cases cover it: the encoder
on a space and on the delimiters, uploads under both names, the created
release id in the path, and the multipart attachment. Mutation-checked:
dropping the encoder fails exactly the two name assertions.
docs/CONSUMERS.md's artifact-hook recovery still told operators to "run
`gh release create` by hand" and described the hook as running "before
`gh release create`" — on a Forgejo runner that is precisely the failure
this PR fixes. It now names the forge-neutral tag-door recovery first and
shows both clients for the manual path, without regressing the GitHub
guidance.
1035 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:11:06 +00:00
|
|
|
# Assets: the hook contract permits any filename the consumer drops in
|
|
|
|
|
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
|
|
|
|
|
# interpolation exits 3 on a space and silently renames on '&' / '#' / '+' /
|
|
|
|
|
# '%' — `gh release create` handled those, so the forgejo twin must too
|
|
|
|
|
# (#191, @codex on !193).
|
|
|
|
|
check "the encoder escapes a space" 0 "release%20asset.tgz" \
|
|
|
|
|
forgejo_urlencode 'release asset.tgz'
|
|
|
|
|
check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
|
|
|
|
|
forgejo_urlencode 'a&b#c+d%e.tgz'
|
|
|
|
|
|
|
|
|
|
printf 'x\n' >"$TMP/release asset.tgz"
|
|
|
|
|
printf 'y\n' >"$TMP/a&b.tgz"
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
|
|
|
|
|
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
|
|
|
|
|
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "the upload targets the created release id" 0 "releases/42/assets" \
|
|
|
|
|
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
|
|
|
|
release_stub 201 '{"id":42}'
|
|
|
|
|
check "the asset rides as a multipart attachment" 0 "attachment=@" \
|
|
|
|
|
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
|
|
|
|
|
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
# --- the github twins address their own paths ----------------------------
|
|
|
|
|
. "$ROOT/lib/forge-github.sh"
|
|
|
|
|
GITHUB_REPOSITORY=o/r
|
|
|
|
|
export GITHUB_REPOSITORY
|
|
|
|
|
GH_CALLS="$TMP/ghcalls"
|
|
|
|
|
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
|
|
|
|
gh() { printf '%s\n' "$*" >>"$GH_CALLS"; case "$*" in *commits/*) echo '[]' ;; esac; return 0; }
|
|
|
|
|
gh_after() { : >"$GH_CALLS"; "$@" >/dev/null 2>&1; cat "$GH_CALLS"; }
|
|
|
|
|
|
|
|
|
|
check "github: the tag goes to /git/refs" 0 "git/refs" \
|
|
|
|
|
gh_after forge_tag_create 1.2.3 cafebabe
|
|
|
|
|
check "github: PRs behind a commit use the PLURAL path" 0 "commits/deadbeef/pulls" \
|
|
|
|
|
gh_after forge_commit_pulls deadbeef
|
2026-08-05 15:01:32 +00:00
|
|
|
# The other half of #209's asymmetry: GitHub serves a single commit at the bare
|
|
|
|
|
# path, with the date nested. Swapping the two backends' paths must red one of
|
|
|
|
|
# these two files, which is the whole point of pinning both.
|
|
|
|
|
check "github: a single commit is the BARE path" 0 "commits/deadbeef" \
|
|
|
|
|
gh_after forge_commit_at deadbeef
|
|
|
|
|
gh_not_git_commits() { ! gh_after forge_commit_at deadbeef | grep -q 'git/commits'; }
|
|
|
|
|
check "...and never Forgejo's git/commits" 0 "" gh_not_git_commits
|
|
|
|
|
check "...reading the nested committer date" 0 "commit.committer.date" \
|
|
|
|
|
gh_after forge_commit_at deadbeef
|
fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
Both panel blockers on c63a550.
@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:
forge_release_exists 0.4.1 -> "no", rc 0
forge_commit_pulls 7fc9afe4 -> "[]", rc 0 (the !189 merge, which HAS a
merged PR behind it)
The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.
Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.
@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.
1029 assertions, 22 suites, shellcheck-all and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
|
|
|
|
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
|