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
|
|
|
|
|
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
|
|
|
|
|
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
|
|
|
|
|
# forgejo; never sourced directly, and never at the same time as the github
|
|
|
|
|
# backend — they define the same verbs on purpose.
|
|
|
|
|
#
|
|
|
|
|
# curl+jq rather than a CLI because that is what the runner has. The image
|
|
|
|
|
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
|
|
|
|
|
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
|
|
|
|
|
|
|
|
|
|
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
|
|
|
|
|
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
|
|
|
|
|
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
|
|
|
|
|
# it for tests and for anyone driving this outside Actions.
|
|
|
|
|
forgejo_api_base() {
|
|
|
|
|
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
|
|
|
|
|
if [ -z "$base" ]; then
|
|
|
|
|
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
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 verb in this backend interpolates $REPO into its path, and every
|
|
|
|
|
# one of them reaches the network through here — so this is the one place
|
|
|
|
|
# that can make `repos//…` impossible.
|
|
|
|
|
#
|
|
|
|
|
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
|
|
|
|
# @kimi on !193 before it shipped):
|
|
|
|
|
#
|
|
|
|
|
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
|
|
|
|
# 404s; a repo-less path read
|
|
|
|
|
# as "the release does not
|
|
|
|
|
# exist" — and the
|
|
|
|
|
# nothing-exists assert would
|
|
|
|
|
# then proceed to CREATE)
|
|
|
|
|
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
|
|
|
|
# PR behind it, read as none)
|
|
|
|
|
#
|
|
|
|
|
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
|
|
|
|
|
# empty and 404s into a fabricated fact instead of crashing. That is the
|
|
|
|
|
# exact failure #191 exists to remove, so it refuses here rather than
|
|
|
|
|
# anywhere later.
|
|
|
|
|
if [ -z "${REPO:-}" ]; then
|
|
|
|
|
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
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 '%s\n' "${base%/}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
|
|
|
|
|
# testable without a network. Returns the endpoint with this backend's OWN
|
|
|
|
|
# paging parameters applied.
|
|
|
|
|
#
|
|
|
|
|
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
|
|
|
|
|
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
|
|
|
|
|
#
|
|
|
|
|
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
|
|
|
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
|
|
|
#
|
|
|
|
|
# Each forge silently ignores the other's page-size parameter, answers
|
|
|
|
|
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
|
|
|
|
|
# was written GitHub-shaped, so a verbatim port would have swept 30 of
|
|
|
|
|
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
|
|
|
|
|
# green, and the same "degraded read that does not report it degraded"
|
|
|
|
|
# failure class this whole issue exists to kill.
|
|
|
|
|
#
|
|
|
|
|
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
|
|
|
|
|
# boundary once beats fixing nine call sites and trusting the tenth — the
|
|
|
|
|
# same argument that chose shape C over B, one level down.
|
|
|
|
|
#
|
|
|
|
|
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
|
|
|
|
|
# this instance) whatever you ask for, so asking for more cannot help and
|
|
|
|
|
# pagination is mandatory rather than an optimisation.
|
|
|
|
|
forgejo_page_url() {
|
|
|
|
|
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
|
|
|
|
|
# Strip any page-size parameter a caller left behind, in either dialect,
|
|
|
|
|
# rather than trusting that none did: this function is the one place that
|
|
|
|
|
# decides paging, and a stray per_page= would be exactly the silent
|
|
|
|
|
# truncation above.
|
|
|
|
|
local clean="$endpoint"
|
|
|
|
|
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
|
|
|
case "$clean" in
|
|
|
|
|
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
|
|
|
|
|
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
|
|
|
|
|
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
|
|
|
|
#
|
|
|
|
|
# --paginate walks page= until a short page, then PROVES the walk was
|
|
|
|
|
# complete by comparing what it collected against the server's declared
|
|
|
|
|
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
|
|
|
|
|
# header is a loud refusal, not a pass. Header exposure is a server setting
|
|
|
|
|
# (access-control-expose-headers), and an instance that withholds it would
|
|
|
|
|
# make the completeness check compare null to a number — the guard itself
|
|
|
|
|
# degrading silently, which is the failure class re-entering through the
|
|
|
|
|
# door built to stop it.
|
|
|
|
|
forge_api() {
|
|
|
|
|
local paginate=false endpoint="" jqexpr="" have_jq=false
|
|
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--paginate) paginate=true ;;
|
|
|
|
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
|
|
|
-*) ;;
|
|
|
|
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
|
|
|
|
|
|
|
|
local base token
|
|
|
|
|
base="$(forgejo_api_base)" || return 1
|
|
|
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
|
|
|
|
|
|
|
|
local hdr body
|
|
|
|
|
hdr="$(mktemp)"; body="$(mktemp)"
|
|
|
|
|
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
|
|
|
|
trap "rm -f '$hdr' '$body'" RETURN
|
|
|
|
|
|
|
|
|
|
if [ "$paginate" = false ]; then
|
|
|
|
|
if ! curl -sS -D "$hdr" -o "$body" \
|
|
|
|
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
|
|
|
"$base/$endpoint"; then
|
|
|
|
|
echo "forge_api: request failed: $endpoint" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
|
|
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Paginated: accumulate into ONE array and apply --jq once at the end.
|
|
|
|
|
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
|
|
|
|
|
# shapes every call site here uses, the two are identical, and merging
|
|
|
|
|
# first is what makes the completeness assert possible at all.
|
|
|
|
|
local page=1 total="" got=0 n all="[]" pagejson
|
|
|
|
|
while :; do
|
|
|
|
|
if ! curl -sS -D "$hdr" -o "$body" \
|
|
|
|
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
|
|
|
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
|
|
|
|
|
echo "forge_api: request failed: $endpoint (page $page)" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
|
|
|
|
2026-08-02 19:07:32 +00:00
|
|
|
# Re-read on EVERY page, not once (#4712). A board that changes size
|
|
|
|
|
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
|
|
|
|
# 9 stopped at 4 believing itself whole. A moving total means the read
|
|
|
|
|
# cannot have been atomic, so it is refused rather than reconciled.
|
|
|
|
|
local page_total
|
|
|
|
|
page_total="$(forgejo_total_count "$hdr")" || return 1
|
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
|
|
|
if [ -z "$total" ]; then
|
2026-08-02 19:07:32 +00:00
|
|
|
total="$page_total"
|
|
|
|
|
elif [ "$page_total" != "$total" ]; then
|
|
|
|
|
cat >&2 <<EOF
|
|
|
|
|
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
|
|
|
|
|
The collection moved under the walk, so no page set can be proven whole.
|
|
|
|
|
Refusing rather than reconciling a board that is already out of date.
|
|
|
|
|
EOF
|
|
|
|
|
return 1
|
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
|
|
|
fi
|
2026-08-02 19:07:32 +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
|
|
|
pagejson="$(cat "$body")"
|
2026-08-02 19:07:32 +00:00
|
|
|
# A 200 whose body is not a collection counted as zero items (#4712),
|
|
|
|
|
# so an error object or a scalar arriving where a list belongs read as
|
|
|
|
|
# a complete EMPTY collection whenever the declared total was 0.
|
|
|
|
|
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
|
|
|
|
|
cat >&2 <<EOF
|
|
|
|
|
forge_api: '$endpoint' did not return a collection (#188).
|
|
|
|
|
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
|
|
|
|
|
Refusing: a body this shim cannot count must not be counted as empty.
|
|
|
|
|
EOF
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
n="$(jq 'length' <<<"$pagejson")"
|
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
|
|
|
[ "$n" -gt 0 ] || break
|
|
|
|
|
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
|
|
|
|
got=$((got + n))
|
|
|
|
|
[ "$got" -lt "$total" ] || break
|
|
|
|
|
page=$((page + 1))
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
# The assert. A short read here is the silent-truncation bug arriving by
|
|
|
|
|
# another route, so it is fatal rather than a warning.
|
|
|
|
|
if [ "$got" -ne "$total" ]; then
|
|
|
|
|
cat >&2 <<EOF
|
|
|
|
|
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
|
|
|
|
Refusing rather than reconciling a partial board: a sweep over part of the
|
|
|
|
|
queue that reports success is the failure this shim exists to prevent.
|
|
|
|
|
EOF
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forgejo_total_count <header-file> — the declared size of the collection.
|
|
|
|
|
# Absent is fatal (#4699): without it the completeness assert cannot run,
|
|
|
|
|
# and an assert that cannot run must not silently pass.
|
|
|
|
|
forgejo_total_count() {
|
|
|
|
|
local hdr="$1" total
|
|
|
|
|
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
|
|
|
|
|
if [ -z "$total" ]; then
|
|
|
|
|
cat >&2 <<EOF
|
|
|
|
|
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
|
|
|
|
The header is exposed by a server setting (access-control-expose-headers).
|
|
|
|
|
Refusing: an unprovable read must not be reported as a whole one.
|
|
|
|
|
EOF
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-08-02 19:07:32 +00:00
|
|
|
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
|
|
|
|
|
# not-a-number` used to sail through and become the bound the walk was
|
|
|
|
|
# compared against — a guard whose own input was never checked.
|
|
|
|
|
case "$total" in
|
|
|
|
|
'' | *[!0-9]*)
|
|
|
|
|
cat >&2 <<EOF
|
|
|
|
|
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
|
|
|
|
|
Refusing: the completeness bound must be a number, or the assert that
|
|
|
|
|
uses it proves nothing.
|
|
|
|
|
EOF
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
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 '%s\n' "$total"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
|
|
|
|
|
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
|
|
|
|
# and -f would throw away the body that says why.
|
|
|
|
|
forgejo_http_ok() {
|
|
|
|
|
local hdr="$1" endpoint="$2" code
|
|
|
|
|
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
|
|
|
|
|
case "$code" in
|
|
|
|
|
2*) return 0 ;;
|
|
|
|
|
*)
|
|
|
|
|
echo "forge_api: HTTP $code from '$endpoint'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
|
|
|
|
|
# --- the verbs the reconcilers use, over /api/v1 --------------------------
|
|
|
|
|
# Three asymmetries with gh, all measured against this instance on
|
|
|
|
|
# 2026-08-02 using a scratch repo (never a live board):
|
|
|
|
|
#
|
|
|
|
|
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
|
|
|
|
|
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
|
|
|
|
|
# DELETE /issues/{n}/labels/149 -> 204
|
|
|
|
|
# So a removal must resolve name -> id first. gh hides this; the shim
|
|
|
|
|
# cannot.
|
|
|
|
|
#
|
|
|
|
|
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
|
|
|
|
|
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
|
|
|
|
|
# a read-modify-write rather than a delete.
|
|
|
|
|
#
|
|
|
|
|
# 3. There is no statusCheckRollup. The portable equivalent is the
|
|
|
|
|
# combined commit status, GET /commits/{sha}/status, which returns
|
|
|
|
|
# {state, statuses[]}.
|
|
|
|
|
|
|
|
|
|
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
|
|
|
|
|
# call site that needs it. Paginated through forge_api, so a repo with more
|
|
|
|
|
# than one page of labels cannot silently lose the tail (#188).
|
|
|
|
|
forgejo_label_ids() {
|
|
|
|
|
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
|
|
|
|
|
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
|
|
|
|
|
forge_issue_edit() {
|
|
|
|
|
local n="${1:?forge_issue_edit: number required}"
|
|
|
|
|
shift
|
|
|
|
|
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
|
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
|
|
|
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
|
|
|
|
|
# given to `gh`, which fails on a flag it does not know; dropping it here
|
|
|
|
|
# instead would turn a port typo into a green no-op — a mutation that
|
|
|
|
|
# silently did not happen, which is precisely this issue's failure class
|
|
|
|
|
# arriving inside the fix for it.
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
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
|
|
|
--add-label | --remove-label | --add-assignee | --remove-assignee)
|
|
|
|
|
if [ "$#" -lt 2 ]; then
|
|
|
|
|
echo "forge_issue_edit: $1 requires a value (#188)" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
IFS=, read -ra v <<<"$2"
|
|
|
|
|
case "$1" in
|
|
|
|
|
--add-label) add_labels+=("${v[@]}") ;;
|
|
|
|
|
--remove-label) rm_labels+=("${v[@]}") ;;
|
|
|
|
|
--add-assignee) add_assignees+=("${v[@]}") ;;
|
|
|
|
|
--remove-assignee) rm_assignees+=("${v[@]}") ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [ "${#add_labels[@]}" -gt 0 ]; then
|
|
|
|
|
local payload
|
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
|
|
|
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
|
|
|
|
local ids id name
|
|
|
|
|
ids="$(forgejo_label_ids)" || return 1
|
|
|
|
|
for name in "${rm_labels[@]}"; do
|
|
|
|
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
|
|
|
# A label the repo does not have is not an error: the reconcilers call
|
|
|
|
|
# --remove-label unconditionally to converge state, and gh's own
|
|
|
|
|
# behaviour there is a no-op.
|
|
|
|
|
[ -n "$id" ] || continue
|
|
|
|
|
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
|
|
|
local current want payload
|
|
|
|
|
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
|
|
|
|
|
want="$(
|
|
|
|
|
{
|
|
|
|
|
printf '%s\n' "$current"
|
|
|
|
|
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
|
|
|
|
|
} | grep -v '^$' | sort -u
|
|
|
|
|
)"
|
|
|
|
|
if [ "${#rm_assignees[@]}" -gt 0 ]; then
|
|
|
|
|
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
|
|
|
|
|
fi
|
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
|
|
|
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forge_issue_comment() {
|
|
|
|
|
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
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
|
|
|
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forge_pr_list() {
|
|
|
|
|
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
|
|
|
|
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
|
|
|
|
# is mapped into the node shape checks_state already parses, so the decision
|
|
|
|
|
# code is untouched.
|
|
|
|
|
forge_pr_view() {
|
|
|
|
|
local n="${1:?forge_pr_view: number required}" pr sha status
|
|
|
|
|
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
|
|
|
|
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
|
|
|
|
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
|
|
|
|
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
|
|
|
|
jq -n --argjson pr "$pr" --argjson st "$status" '
|
|
|
|
|
{
|
|
|
|
|
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
|
|
|
|
elif $pr.mergeable == false then "CONFLICTING"
|
|
|
|
|
else "UNKNOWN" end),
|
|
|
|
|
statusCheckRollup: [
|
|
|
|
|
$st.statuses[]? | {
|
|
|
|
|
__typename: "StatusContext",
|
|
|
|
|
context: .context,
|
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
|
|
|
state: (.status | ascii_upcase),
|
|
|
|
|
# checks_state groups repeated contexts and takes the NEWEST by
|
|
|
|
|
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
|
|
|
|
|
# winner would be decided by incidental array order, so a stale
|
|
|
|
|
# re-run could outrank the live verdict (#4743). The combined
|
|
|
|
|
# status carries both fields; measured on this instance.
|
|
|
|
|
createdAt: .created_at,
|
|
|
|
|
completedAt: .updated_at
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
|
|
|
|
|
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_label_create — an UPSERT, matching `gh label create --force` (#4743).
|
|
|
|
|
# bootstrap_labels creates every declared label on every workflow_dispatch, so
|
|
|
|
|
# the second dispatch must update rather than conflict; a plain POST onto an
|
|
|
|
|
# existing name aborts the bootstrap under set -e.
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
forge_label_create() {
|
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
|
|
|
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
|
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
|
|
|
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
|
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
|
|
|
ids="$(forgejo_label_ids)" || return 1
|
|
|
|
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
|
|
|
if [ -n "$id" ]; then
|
|
|
|
|
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
|
|
|
|
|
else
|
|
|
|
|
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
|
|
|
|
|
fi
|
feat(forge): the reconciler verb surface on both backends
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
2026-08-02 19:13:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forge_label_delete() {
|
|
|
|
|
local name="${1:?}" ids id
|
|
|
|
|
ids="$(forgejo_label_ids)" || return 1
|
|
|
|
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
|
|
|
[ -n "$id" ] || return 0
|
|
|
|
|
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
|
|
|
|
|
# here so a non-2xx is named rather than swallowed, the same contract
|
|
|
|
|
# forgejo_http_ok gives reads.
|
|
|
|
|
forgejo_write() {
|
|
|
|
|
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
|
|
|
|
|
base="$(forgejo_api_base)" || return 1
|
|
|
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
|
|
|
hdr="$(mktemp)"; body="$(mktemp)"
|
|
|
|
|
if [ -n "$payload" ]; then
|
|
|
|
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
|
|
|
-H "Authorization: token $token" -H 'Content-Type: application/json' \
|
|
|
|
|
-d "$payload" "$base/$endpoint"
|
|
|
|
|
else
|
|
|
|
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
|
|
|
|
-H "Authorization: token $token" "$base/$endpoint"
|
|
|
|
|
fi
|
|
|
|
|
rc=$?
|
|
|
|
|
if [ "$rc" -ne 0 ]; then
|
|
|
|
|
rm -f "$hdr" "$body"
|
|
|
|
|
echo "forge: $method $endpoint failed to send" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
|
|
|
|
|
head -c 300 "$body" >&2; echo >&2
|
|
|
|
|
rm -f "$hdr" "$body"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
cat "$body"
|
|
|
|
|
rm -f "$hdr" "$body"
|
|
|
|
|
}
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
|
|
|
|
|
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
|
|
|
|
|
# the github twin). POST /issues/{n}/labels adds the named labels and removes
|
|
|
|
|
# nothing, and it takes NAMES — measured, unlike the removal path, which
|
|
|
|
|
# needs ids.
|
|
|
|
|
forge_labels_add() {
|
|
|
|
|
local n="${1:?forge_labels_add: number required}"
|
|
|
|
|
shift
|
|
|
|
|
[ "$#" -gt 0 ] || return 0
|
|
|
|
|
forgejo_write POST "repos/$REPO/issues/$n/labels" \
|
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
|
|
|
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
|
|
|
|
#
|
|
|
|
|
# This endpoint DOES exist here, contrary to an earlier reading of mine
|
|
|
|
|
# (#4698) which recorded requested_reviewers as having no sub-resource at
|
|
|
|
|
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
|
|
|
|
|
# GET, so a GET probe answers 404 — and a POST naming a user who does not
|
|
|
|
|
# exist answers 404 as well, for a different reason. Measured on a scratch
|
|
|
|
|
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
|
|
|
|
|
# read"), and 201 once they have it.
|
|
|
|
|
#
|
|
|
|
|
# The READ stays retired regardless (term 4): the field is stale here even on
|
|
|
|
|
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
|
|
|
|
|
# current head SHA. It is the write that has an answer.
|
|
|
|
|
forge_request_reviewer() {
|
|
|
|
|
local n="${1:?}" user="${2:?}"
|
|
|
|
|
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
|
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
|
|
|
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
|
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
|
|
|
}
|
2026-08-03 15:13:30 +00:00
|
|
|
|
|
|
|
|
# forge_timeline <n> — JSON array of timeline events projected into the
|
|
|
|
|
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
|
|
|
|
|
#
|
|
|
|
|
# | | GitHub | Forgejo |
|
|
|
|
|
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
|
|
|
|
|
# | add vs remove | the two event names | .body "1" / "" |
|
|
|
|
|
# | actor | .actor.login (no .user) | .user.login (no .actor) |
|
|
|
|
|
#
|
|
|
|
|
# Status is captured BEFORE jq so an unreadable read cannot report as an
|
|
|
|
|
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
|
|
|
|
forge_timeline() {
|
|
|
|
|
local n="${1:?forge_timeline: number required}" raw
|
|
|
|
|
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
|
|
|
|
jq '
|
|
|
|
|
[.[]
|
|
|
|
|
| select(.type == "label")
|
|
|
|
|
| {
|
|
|
|
|
event: (if .body == "1" then "labeled" else "unlabeled" end),
|
|
|
|
|
actor: {login: (.user.login // "")},
|
|
|
|
|
label: {name: (.label.name // "")},
|
|
|
|
|
created_at: .created_at
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
' <<<"$raw"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
|
|
|
|
|
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
|
|
|
|
|
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
|
|
|
|
|
# with comments_count > 0 are fetched, so a board with none costs zero
|
|
|
|
|
# extra requests.
|
|
|
|
|
forge_pr_activity() {
|
|
|
|
|
local n="${1:?forge_pr_activity: number required}" reviews rid
|
|
|
|
|
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
|
|
|
|
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
|
|
|
|
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
|
|
|
|
|
while IFS= read -r rid; do
|
|
|
|
|
[ -n "$rid" ] || continue
|
|
|
|
|
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
|
|
|
|
|
--jq '.[].created_at' || return 1
|
|
|
|
|
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
|
|
|
|
}
|
2026-08-04 11:31:06 +00:00
|
|
|
|
|
|
|
|
# --- the release door's facts (#191) --------------------------------------
|
|
|
|
|
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
|
|
|
|
|
# both distinguish "the read completed and the answer is no" from "the read
|
|
|
|
|
# did not complete" — the distinction lib/facts.sh got wrong before #191,
|
|
|
|
|
# where any failure became a definite `no` and a release ceremony was
|
|
|
|
|
# silently demoted to a bare push.
|
|
|
|
|
#
|
|
|
|
|
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
|
|
|
|
|
#
|
|
|
|
|
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
|
|
|
|
|
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
|
|
|
|
|
#
|
|
|
|
|
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
|
|
|
|
|
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
|
|
|
|
|
# ...on a commit with no PR -> 404 {"message":"pull request
|
|
|
|
|
# does not exist …"}
|
|
|
|
|
#
|
|
|
|
|
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
|
|
|
|
|
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
|
|
|
|
|
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
|
|
|
|
|
# for both forges, which is the whole point of the shim.
|
|
|
|
|
|
|
|
|
|
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
|
|
|
|
|
# status on stdout. Separate from forge_api because these two call sites
|
|
|
|
|
# must SEE a 404 rather than have it collapsed into a failure.
|
|
|
|
|
forgejo_read_code() {
|
|
|
|
|
local endpoint="$1" body="$2" base token hdr rc
|
|
|
|
|
base="$(forgejo_api_base)" || return 1
|
|
|
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
|
|
|
hdr="$(mktemp)"
|
|
|
|
|
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
|
|
|
|
|
rc=$?
|
|
|
|
|
if [ "$rc" -ne 0 ]; then
|
|
|
|
|
rm -f "$hdr"
|
|
|
|
|
echo "forge: GET $endpoint failed to send" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
|
|
|
|
|
rm -f "$hdr"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
|
|
|
|
# the read did not complete and the answer is UNKNOWN; the caller must not
|
|
|
|
|
# treat that as `no` (#191).
|
|
|
|
|
forge_release_exists() {
|
|
|
|
|
local tag="${1:?forge_release_exists: tag required}" body code
|
|
|
|
|
body="$(mktemp)"
|
|
|
|
|
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
|
|
|
|
rm -f "$body"
|
|
|
|
|
case "$code" in
|
|
|
|
|
2*) echo yes ;;
|
|
|
|
|
404) echo no ;;
|
|
|
|
|
*)
|
|
|
|
|
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
|
|
|
|
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
|
|
|
|
|
# found nothing; a non-zero exit is a read that did not complete.
|
|
|
|
|
forge_commit_pulls() {
|
|
|
|
|
local sha="${1:?forge_commit_pulls: sha required}" body code out
|
|
|
|
|
body="$(mktemp)"
|
|
|
|
|
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
|
|
|
|
|
case "$code" in
|
|
|
|
|
2*)
|
|
|
|
|
# One object -> a one-element array, so the call site's jq is the
|
|
|
|
|
# same expression it runs against GitHub.
|
|
|
|
|
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
|
|
|
|
|
rm -f "$body"
|
|
|
|
|
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
printf '%s\n' "$out"
|
|
|
|
|
;;
|
|
|
|
|
404) printf '[]\n' ;;
|
|
|
|
|
*)
|
|
|
|
|
rm -f "$body"
|
|
|
|
|
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
rm -f "$body"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --- the release door's writes (#191) -------------------------------------
|
|
|
|
|
# Confirmed against this instance's own swagger, 2026-08-04:
|
|
|
|
|
#
|
|
|
|
|
# POST /repos/{o}/{r}/tags -> exists (tag creation)
|
|
|
|
|
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
|
|
|
|
|
# POST /repos/{o}/{r}/releases -> exists
|
|
|
|
|
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
|
|
|
|
|
#
|
|
|
|
|
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
|
|
|
|
|
# /git/refs; Forgejo does not serve POST there at all and creates tags at
|
|
|
|
|
# /tags instead. A 1:1 port of the gh call would 404 forever.
|
|
|
|
|
|
|
|
|
|
# forge_tag_create <tag> <sha>
|
|
|
|
|
forge_tag_create() {
|
|
|
|
|
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
|
|
|
|
forgejo_write POST "repos/$REPO/tags" \
|
|
|
|
|
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
|
|
|
|
|
# a hard dependency of this backend, and @uri is its one correct answer; a
|
|
|
|
|
# hand-rolled sed class is how the next unescaped character gets through.
|
|
|
|
|
forgejo_urlencode() {
|
|
|
|
|
jq -rn --arg s "${1-}" '$s|@uri'
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:31:06 +00:00
|
|
|
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
|
|
|
|
# uploads each asset to the created release. The release id comes back from
|
|
|
|
|
# the create, so no second lookup is needed.
|
|
|
|
|
forge_release_create() {
|
|
|
|
|
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
|
|
|
|
local notes="${3:?forge_release_create: notes file required}" out id base token
|
|
|
|
|
shift 3
|
|
|
|
|
out="$(forgejo_write POST "repos/$REPO/releases" \
|
|
|
|
|
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
|
|
|
|
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
|
|
|
|
|
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
|
|
|
|
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
|
|
|
|
[ "$#" -gt 0 ] || return 0
|
|
|
|
|
base="$(forgejo_api_base)" || return 1
|
|
|
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
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
|
|
|
local f name
|
2026-08-04 11:31:06 +00:00
|
|
|
for f in "$@"; do
|
|
|
|
|
[ -e "$f" ] || continue
|
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
|
|
|
# The asset name is a QUERY VALUE, and the hook contract permits any
|
|
|
|
|
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
|
|
|
|
|
# on a space (curl exits 3 on the malformed URL) and silently changed
|
|
|
|
|
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
|
|
|
|
|
# so a 1:1 port had to as well (#191, found by @codex on !193).
|
|
|
|
|
name="$(forgejo_urlencode "$(basename "$f")")"
|
2026-08-04 11:31:06 +00:00
|
|
|
curl -sS -f -X POST -H "Authorization: token $token" \
|
|
|
|
|
-F "attachment=@$f" \
|
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
|
|
|
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
2026-08-04 11:31:06 +00:00
|
|
|
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
|
|
|
|
# label IDs, not names (the same asymmetry the issue-label writes carry), so
|
|
|
|
|
# the names are resolved first through forgejo_label_ids.
|
|
|
|
|
forge_pr_create() {
|
|
|
|
|
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
|
|
|
|
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
|
|
|
|
shift 4
|
|
|
|
|
local ids='[]' map name id
|
|
|
|
|
if [ "$#" -gt 0 ]; then
|
|
|
|
|
map="$(forgejo_label_ids)" || return 1
|
|
|
|
|
ids='['
|
|
|
|
|
for name in "$@"; do
|
|
|
|
|
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
|
|
|
|
|
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
|
|
|
|
|
ids="$ids$id,"
|
|
|
|
|
done
|
|
|
|
|
ids="${ids%,}]"
|
|
|
|
|
fi
|
|
|
|
|
forgejo_write POST "repos/$REPO/pulls" \
|
|
|
|
|
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
|
|
|
|
|
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
|
|
|
|
|
}
|