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):
|
|
|
|
|
#
|
2026-08-30 09:25:43 +00:00
|
|
|
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
|
|
|
|
# 404s; a repo-less path read
|
|
|
|
|
# as "the published release
|
|
|
|
|
# does not exist" — and the
|
|
|
|
|
# nothing-exists assert would
|
|
|
|
|
# then proceed to CREATE)
|
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
|
|
|
# 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 17:53:13 +00:00
|
|
|
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
|
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
|
|
|
#
|
|
|
|
|
# --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.
|
2026-08-24 17:53:13 +00:00
|
|
|
#
|
|
|
|
|
# --paginate-exhaustive is the narrow alternative for an endpoint whose
|
|
|
|
|
# x-total-count is known not to describe the collection. It proves completion
|
|
|
|
|
# by reading through the first short page and never consults that header.
|
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
|
|
|
forge_api() {
|
2026-08-24 17:53:13 +00:00
|
|
|
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
|
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
|
|
|
while [ $# -gt 0 ]; do
|
|
|
|
|
case "$1" in
|
|
|
|
|
--paginate) paginate=true ;;
|
2026-08-24 17:53:13 +00:00
|
|
|
--paginate-exhaustive) paginate_exhaustive=true ;;
|
|
|
|
|
-X | --method)
|
|
|
|
|
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
|
|
|
|
|
method="$2"
|
|
|
|
|
shift
|
|
|
|
|
;;
|
2026-08-24 18:01:04 +00:00
|
|
|
-X?*) method="${1#-X}" ;;
|
|
|
|
|
--method=*)
|
|
|
|
|
method="${1#*=}"
|
|
|
|
|
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; 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
|
|
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
|
|
|
-*) ;;
|
|
|
|
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
|
|
|
esac
|
|
|
|
|
shift
|
|
|
|
|
done
|
|
|
|
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
2026-08-24 17:53:13 +00:00
|
|
|
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
|
|
|
|
|
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
|
|
|
|
|
echo "forge_api: pagination is available only for GET requests" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
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
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-24 17:53:13 +00:00
|
|
|
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
|
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 ! 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
|
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
|
|
|
forgejo_http_ok "$hdr" "GET $endpoint" || 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 [ "$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
|
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
|
|
|
forgejo_http_ok "$hdr" "GET $endpoint" || 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
|
|
|
|
2026-08-24 17:53:13 +00:00
|
|
|
if [ "$paginate_exhaustive" = false ]; then
|
|
|
|
|
# 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
|
|
|
|
|
if [ -z "$total" ]; then
|
|
|
|
|
total="$page_total"
|
|
|
|
|
elif [ "$page_total" != "$total" ]; then
|
|
|
|
|
cat >&2 <<EOF
|
2026-08-02 19:07:32 +00:00
|
|
|
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
|
2026-08-24 17:53:13 +00:00
|
|
|
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
|
|
|
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))
|
2026-08-24 17:53:13 +00:00
|
|
|
if [ "$paginate_exhaustive" = true ]; then
|
|
|
|
|
[ "$n" -eq 50 ] || break
|
|
|
|
|
else
|
|
|
|
|
[ "$got" -lt "$total" ] || break
|
|
|
|
|
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
|
|
|
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.
|
2026-08-24 17:53:13 +00:00
|
|
|
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
|
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
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
# forgejo_http_ok <header-file> <verb-and-endpoint> — a non-2xx is named, not
|
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
|
|
|
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
|
|
|
|
# and -f would throw away the body that says why.
|
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
|
|
|
# The second argument carries the VERB as well as the path — "GET repos/…",
|
|
|
|
|
# "PUT repos/…". #192's acceptance criterion is that a failure names the verb,
|
|
|
|
|
# the path and the status, and reads used to omit the verb: a caller reading
|
|
|
|
|
# `HTTP 500 from 'repos/o/r/issues/5'` could not tell a failed read from a
|
|
|
|
|
# failed write of the same path (@codex-reviewer-andresmgsl).
|
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
|
|
|
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
|
|
|
|
|
|
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 LABEL DELTA (#192). Removal used to be 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
|
|
|
|
|
# under a real Actions token inside a workflow, probe run 701:
|
|
|
|
|
#
|
|
|
|
|
# POST /issues/{n}/labels ["probe-a","probe-b"] -> 200
|
|
|
|
|
# DELETE /issues/{n}/labels/{id} -> 500 labels unchanged
|
|
|
|
|
# PUT /issues/{n}/labels {"labels":[<id>]} -> 200
|
|
|
|
|
# PUT /issues/{n}/labels {"labels":[]} -> 200 (full clear)
|
|
|
|
|
#
|
|
|
|
|
# A PAT gets 204 on the same DELETE, which is why this survived a week
|
|
|
|
|
# unseen: it fails only for `${{ github.token }}`, and only inside Actions.
|
|
|
|
|
# Net effect before this fix: 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.
|
|
|
|
|
#
|
|
|
|
|
# So a removal is expressed as a full-set PUT, exactly as the assignee branch
|
|
|
|
|
# below expresses its own delta as one PATCH — read current, compute wanted,
|
|
|
|
|
# write once.
|
|
|
|
|
#
|
|
|
|
|
# AN ADD-ONLY CALL KEEPS ITS ADDITIVE POST, deliberately. ceremony#128 lost
|
|
|
|
|
# its `release` label — the merge door's declared-intent read — to a
|
|
|
|
|
# read-modify-write that clobbered a label set two seconds after a builder
|
|
|
|
|
# wrote it, and `forge_labels_add` is pinned against ever doing that
|
|
|
|
|
# (test/forge-backends.test.sh). The read-modify-write window is real and is
|
|
|
|
|
# accepted HERE and only here, where the caller has asked to REMOVE something
|
|
|
|
|
# and no additive verb can express that.
|
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
|
|
|
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
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
|
|
|
local current want_pairs ids id name payload
|
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
|
|
|
local want_ids=() missing=()
|
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
|
|
|
# name<TAB>id straight from the ISSUE payload. Preserved labels carry
|
|
|
|
|
# their authoritative id here already, so they need no second lookup —
|
|
|
|
|
# re-resolving them through the repository-wide list would make
|
|
|
|
|
# preservation depend on a paginated read that has nothing to do with
|
|
|
|
|
# this issue, and an incomplete one would drop a bystander
|
|
|
|
|
# (@codex-reviewer-andresmgsl). Only ADDED names need forgejo_label_ids.
|
|
|
|
|
current="$(forge_api "repos/$REPO/issues/$n" \
|
|
|
|
|
--jq '[.labels[]? | "\(.name)\t\(.id)"] | join("\n")')" || return 1
|
|
|
|
|
# The rows are name<TAB>id, so removals filter on the NAME field — a
|
|
|
|
|
# whole-line match would never fire against a pair.
|
|
|
|
|
want_pairs="$(
|
|
|
|
|
awk -F '\t' 'NR==FNR { drop[$0]=1; next } !($1 in drop)' \
|
|
|
|
|
<(printf '%s\n' "${rm_labels[@]}") \
|
|
|
|
|
<(printf '%s\n' "$current" | grep -v '^$')
|
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
|
|
|
)"
|
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
|
|
|
while IFS=$'\t' read -r name id; do
|
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
|
|
|
[ -n "$name" ] || continue
|
|
|
|
|
want_ids+=("$id")
|
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
|
|
|
done <<<"$want_pairs"
|
|
|
|
|
if [ "${#add_labels[@]}" -gt 0 ]; then
|
|
|
|
|
ids="$(forgejo_label_ids)" || return 1
|
|
|
|
|
for name in "${add_labels[@]}"; do
|
|
|
|
|
# already on the issue? its id is in want_ids already
|
|
|
|
|
awk -F '\t' -v want="$name" '$1 == want { found=1 } END { exit !found }' \
|
|
|
|
|
<<<"$want_pairs" && continue
|
|
|
|
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
|
|
|
|
if [ -z "$id" ]; then missing+=("$name"); continue; fi
|
|
|
|
|
want_ids+=("$id")
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
# An add-label the repo does not carry refuses BEFORE the 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.
|
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
|
|
|
if [ "${#missing[@]}" -gt 0 ]; then
|
|
|
|
|
echo "forge_issue_edit: #$n: no label id on $REPO for: ${missing[*]} — refusing to PUT a set that would drop it" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
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
|
|
|
# NOTHING TO CHANGE, NOTHING TO WRITE. The reconcilers call
|
|
|
|
|
# --remove-label unconditionally to converge state, so most calls here ask
|
|
|
|
|
# to remove a label the issue does not carry. Writing the unchanged set
|
|
|
|
|
# back would open the read-modify-write window of ceremony#128 for no
|
|
|
|
|
# state change at all; the GET above is already the proof the sweep
|
|
|
|
|
# reached the forge (@codex-reviewer-andresmgsl). gh's own behaviour on an
|
|
|
|
|
# absent --remove-label is likewise a no-op.
|
|
|
|
|
local current_ids
|
|
|
|
|
current_ids="$(printf '%s\n' "$current" | grep -v '^$' | cut -f2 | sort -n | tr '\n' ' ')"
|
|
|
|
|
if [ "$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} | grep -v '^$' | sort -n | tr '\n' ' ')" = "$current_ids" ]; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
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
|
|
|
payload="$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} \
|
|
|
|
|
| jq -R 'select(. != "") | tonumber' | jq -sc '{labels: .}')"
|
|
|
|
|
forgejo_write PUT "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
|
|
|
|
elif [ "${#add_labels[@]}" -gt 0 ]; then
|
|
|
|
|
local payload
|
|
|
|
|
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
|
|
|
|
|
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || 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
|
|
|
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" '
|
|
|
|
|
{
|
2026-08-23 17:41:10 +00:00
|
|
|
# Forgejo folds checking, conflict, check error, and WIP into false.
|
|
|
|
|
# Draft must win because WIP makes the boolean carry no merge result (#236).
|
|
|
|
|
mergeable: (if $pr.draft == true then "UNKNOWN"
|
|
|
|
|
elif $pr.mergeable == true then "MERGEABLE"
|
|
|
|
|
elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
|
|
|
|
|
else "CONFLICTING" end),
|
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
|
|
|
statusCheckRollup: [
|
|
|
|
|
$st.statuses[]? | {
|
|
|
|
|
__typename: "StatusContext",
|
|
|
|
|
context: .context,
|
2026-08-25 08:42:53 +00:00
|
|
|
# Forgejo carries the workflow name only as the context prefix;
|
|
|
|
|
# no separator means no proven workflow, so never guess (#243).
|
|
|
|
|
workflowName: ((.context // "")
|
|
|
|
|
| if contains(" / ") then split(" / ")[0] else "" end),
|
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
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}'
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 12:38:03 +00:00
|
|
|
# forge_pr_review_requests <n> — logins with a live review request.
|
|
|
|
|
# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any
|
|
|
|
|
# review, so these rows are the exact live set rather than review history (#238).
|
|
|
|
|
forge_pr_review_requests() {
|
|
|
|
|
local n="${1:?forge_pr_review_requests: number required}"
|
|
|
|
|
forge_api --paginate "repos/$REPO/pulls/$n/reviews" \
|
|
|
|
|
--jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-08-24 17:53:13 +00:00
|
|
|
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
|
|
|
|
|
# reports 50, while crew!96 held 151 events and strict pagination returned
|
|
|
|
|
# only its first 50. No other measured endpoint echoes its page size this
|
|
|
|
|
# way. Timelines are append-only, so exhaustion can include concurrent new
|
|
|
|
|
# events but cannot create a deletion hole; that is why only this call site
|
|
|
|
|
# may bypass the header-bound completeness proof (#240).
|
|
|
|
|
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
|
2026-08-03 15:13:30 +00:00
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 09:25:43 +00:00
|
|
|
# forge_release_exists <tag> — prints `yes` for a published release and `no`
|
|
|
|
|
# for a draft or a 404. A non-zero exit means the read did not complete and
|
|
|
|
|
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
|
2026-08-04 11:31:06 +00:00
|
|
|
forge_release_exists() {
|
2026-08-30 09:25:43 +00:00
|
|
|
local tag="${1:?forge_release_exists: tag required}" body code draft
|
2026-08-04 11:31:06 +00:00
|
|
|
body="$(mktemp)"
|
|
|
|
|
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
|
|
|
|
case "$code" in
|
2026-08-30 09:25:43 +00:00
|
|
|
2*)
|
|
|
|
|
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|
|
|
|
|
|| [[ "$draft" != true && "$draft" != false ]]; then
|
|
|
|
|
rm -f "$body"
|
|
|
|
|
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
[ "$draft" = true ] && echo no || echo yes
|
|
|
|
|
;;
|
2026-08-04 11:31:06 +00:00
|
|
|
404) echo no ;;
|
|
|
|
|
*)
|
2026-08-30 09:25:43 +00:00
|
|
|
rm -f "$body"
|
2026-08-04 11:31:06 +00:00
|
|
|
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
2026-08-30 09:25:43 +00:00
|
|
|
rm -f "$body"
|
2026-08-04 11:31:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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.
|
2026-08-05 15:01:32 +00:00
|
|
|
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
|
|
|
|
|
#
|
|
|
|
|
# THE FOURTH ASYMMETRY (#209), measured 2026-08-05:
|
|
|
|
|
#
|
|
|
|
|
# GET /repos/{o}/{r}/commits/{sha} -> 404 (200 on GitHub)
|
|
|
|
|
# GET /repos/{o}/{r}/git/commits/{sha} -> 200 date under `.created`
|
|
|
|
|
#
|
|
|
|
|
# Found by the first post-merge sweep after the 0.6.0 merge, not by review:
|
|
|
|
|
# #198 ported this call site onto the shim with GitHub's path unchanged, and
|
|
|
|
|
# the block it lives in had never executed here before. Every sweep printed
|
|
|
|
|
# `could not read the head commit's date` and left blocker:unrequested
|
|
|
|
|
# unjudged.
|
|
|
|
|
#
|
|
|
|
|
# `.created` and not `.commit.committer.date`: the /git/commits payload is the
|
|
|
|
|
# git object, whose top-level `created` is the committer date. The verb hides
|
|
|
|
|
# both differences so the caller keeps asking for one timestamp.
|
|
|
|
|
forge_commit_at() {
|
|
|
|
|
local sha="${1:?forge_commit_at: sha required}"
|
|
|
|
|
forge_api "repos/$REPO/git/commits/$sha" --jq '.created'
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:31:06 +00:00
|
|
|
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-30 09:25:43 +00:00
|
|
|
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
|
|
|
|
|
# post-create failure. The caller has already printed the original failure;
|
|
|
|
|
# cleanup can add evidence but must never replace that diagnosis (#271).
|
|
|
|
|
forgejo_release_cleanup_draft() {
|
|
|
|
|
local id="${1:?forgejo_release_cleanup_draft: id required}"
|
|
|
|
|
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
|
|
|
|
|
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
|
|
|
|
|
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
|
|
|
|
|
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
|
|
|
|
|
fi
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
|
|
|
|
|
# uploads every asset, then publishes it. Any failure after create removes the
|
|
|
|
|
# draft, so the function leaves either a complete published release or nothing.
|
2026-08-04 11:31:06 +00:00
|
|
|
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
|
2026-08-30 09:25:43 +00:00
|
|
|
local existing code draft existing_id
|
2026-08-04 11:31:06 +00:00
|
|
|
shift 3
|
2026-08-30 09:25:43 +00:00
|
|
|
|
|
|
|
|
# A previous rollback whose DELETE failed must not wedge the next attempt.
|
|
|
|
|
# Remove only a draft for this exact tag; a published release is never
|
|
|
|
|
# touched and its create will retain Forgejo's ordinary conflict refusal.
|
|
|
|
|
existing="$(mktemp)"
|
|
|
|
|
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
|
|
|
|
|
case "$code" in
|
|
|
|
|
2*)
|
|
|
|
|
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|
|
|
|
|
|| [[ "$draft" != true && "$draft" != false ]]; then
|
|
|
|
|
rm -f "$existing"
|
|
|
|
|
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if [ "$draft" = true ]; then
|
|
|
|
|
existing_id="$(jq -r '.id // empty' "$existing")"
|
|
|
|
|
if [ -z "$existing_id" ]; then
|
|
|
|
|
rm -f "$existing"
|
|
|
|
|
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
|
|
|
|
|
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
|
|
|
|
|
rm -f "$existing"
|
|
|
|
|
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
404) ;;
|
|
|
|
|
*)
|
|
|
|
|
rm -f "$existing"
|
|
|
|
|
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
rm -f "$existing"
|
|
|
|
|
|
2026-08-04 11:31:06 +00:00
|
|
|
out="$(forgejo_write POST "repos/$REPO/releases" \
|
|
|
|
|
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
2026-08-30 09:25:43 +00:00
|
|
|
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
|
2026-08-04 11:31:06 +00:00
|
|
|
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
|
|
|
|
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
2026-08-30 09:25:43 +00:00
|
|
|
if ! base="$(forgejo_api_base)"; then
|
|
|
|
|
forgejo_release_cleanup_draft "$id" "$tag"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-08-04 11:31:06 +00:00
|
|
|
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-30 09:25:43 +00:00
|
|
|
|| {
|
|
|
|
|
echo "forge_release_create: asset upload failed for '$f'" >&2
|
|
|
|
|
forgejo_release_cleanup_draft "$id" "$tag"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
2026-08-04 11:31:06 +00:00
|
|
|
done
|
2026-08-30 09:25:43 +00:00
|
|
|
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
|
|
|
|
|
forgejo_release_cleanup_draft "$id" "$tag"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-08-04 11:31:06 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
}
|