All checks were successful
CI / test (pull_request) Successful in 1m35s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 56s
@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
743 lines
33 KiB
Bash
743 lines
33 KiB
Bash
#!/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
|
|
# Every verb in this backend interpolates $REPO into its path, and every
|
|
# one of them reaches the network through here — so this is the one place
|
|
# that can make `repos//…` impossible.
|
|
#
|
|
# THE TRAP, measured on this instance with REPO unset (#191, caught by
|
|
# @kimi on !193 before it shipped):
|
|
#
|
|
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
|
|
# 404s; a repo-less path read
|
|
# as "the release does not
|
|
# exist" — and the
|
|
# nothing-exists assert would
|
|
# then proceed to CREATE)
|
|
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
|
|
# PR behind it, read as none)
|
|
#
|
|
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
|
|
# empty and 404s into a fabricated fact instead of crashing. That is the
|
|
# exact failure #191 exists to remove, so it refuses here rather than
|
|
# anywhere later.
|
|
if [ -z "${REPO:-}" ]; then
|
|
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "${base%/}"
|
|
}
|
|
|
|
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
|
|
# testable without a network. Returns the endpoint with this backend's OWN
|
|
# paging parameters applied.
|
|
#
|
|
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
|
|
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
|
|
#
|
|
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
|
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
|
#
|
|
# Each forge silently ignores the other's page-size parameter, answers
|
|
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
|
|
# was written GitHub-shaped, so a verbatim port would have swept 30 of
|
|
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
|
|
# green, and the same "degraded read that does not report it degraded"
|
|
# failure class this whole issue exists to kill.
|
|
#
|
|
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
|
|
# boundary once beats fixing nine call sites and trusting the tenth — the
|
|
# same argument that chose shape C over B, one level down.
|
|
#
|
|
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
|
|
# this instance) whatever you ask for, so asking for more cannot help and
|
|
# pagination is mandatory rather than an optimisation.
|
|
forgejo_page_url() {
|
|
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
|
|
# Strip any page-size parameter a caller left behind, in either dialect,
|
|
# rather than trusting that none did: this function is the one place that
|
|
# decides paging, and a stray per_page= would be exactly the silent
|
|
# truncation above.
|
|
local clean="$endpoint"
|
|
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
|
case "$clean" in
|
|
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
|
|
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
|
|
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
|
|
esac
|
|
}
|
|
|
|
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
|
#
|
|
# --paginate walks page= until a short page, then PROVES the walk was
|
|
# complete by comparing what it collected against the server's declared
|
|
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
|
|
# header is a loud refusal, not a pass. Header exposure is a server setting
|
|
# (access-control-expose-headers), and an instance that withholds it would
|
|
# make the completeness check compare null to a number — the guard itself
|
|
# degrading silently, which is the failure class re-entering through the
|
|
# door built to stop it.
|
|
forge_api() {
|
|
local paginate=false endpoint="" jqexpr="" have_jq=false
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--paginate) paginate=true ;;
|
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
|
-*) ;;
|
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
|
esac
|
|
shift
|
|
done
|
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
|
|
|
local base token
|
|
base="$(forgejo_api_base)" || return 1
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
|
|
local hdr body
|
|
hdr="$(mktemp)"; body="$(mktemp)"
|
|
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
|
trap "rm -f '$hdr' '$body'" RETURN
|
|
|
|
if [ "$paginate" = false ]; then
|
|
if ! curl -sS -D "$hdr" -o "$body" \
|
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
"$base/$endpoint"; then
|
|
echo "forge_api: request failed: $endpoint" >&2
|
|
return 1
|
|
fi
|
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
|
|
return 0
|
|
fi
|
|
|
|
# Paginated: accumulate into ONE array and apply --jq once at the end.
|
|
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
|
|
# shapes every call site here uses, the two are identical, and merging
|
|
# first is what makes the completeness assert possible at all.
|
|
local page=1 total="" got=0 n all="[]" pagejson
|
|
while :; do
|
|
if ! curl -sS -D "$hdr" -o "$body" \
|
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
|
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
|
|
echo "forge_api: request failed: $endpoint (page $page)" >&2
|
|
return 1
|
|
fi
|
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
|
|
|
# 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
|
|
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
|
|
The collection moved under the walk, so no page set can be proven whole.
|
|
Refusing rather than reconciling a board that is already out of date.
|
|
EOF
|
|
return 1
|
|
fi
|
|
|
|
pagejson="$(cat "$body")"
|
|
# 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")"
|
|
[ "$n" -gt 0 ] || break
|
|
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
|
got=$((got + n))
|
|
[ "$got" -lt "$total" ] || break
|
|
page=$((page + 1))
|
|
done
|
|
|
|
# The assert. A short read here is the silent-truncation bug arriving by
|
|
# another route, so it is fatal rather than a warning.
|
|
if [ "$got" -ne "$total" ]; then
|
|
cat >&2 <<EOF
|
|
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
|
Refusing rather than reconciling a partial board: a sweep over part of the
|
|
queue that reports success is the failure this shim exists to prevent.
|
|
EOF
|
|
return 1
|
|
fi
|
|
|
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
|
}
|
|
|
|
# forgejo_total_count <header-file> — the declared size of the collection.
|
|
# Absent is fatal (#4699): without it the completeness assert cannot run,
|
|
# and an assert that cannot run must not silently pass.
|
|
forgejo_total_count() {
|
|
local hdr="$1" total
|
|
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
|
|
if [ -z "$total" ]; then
|
|
cat >&2 <<EOF
|
|
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
|
The header is exposed by a server setting (access-control-expose-headers).
|
|
Refusing: an unprovable read must not be reported as a whole one.
|
|
EOF
|
|
return 1
|
|
fi
|
|
# 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
|
|
printf '%s\n' "$total"
|
|
}
|
|
|
|
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
|
|
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
|
# and -f would throw away the body that says why.
|
|
forgejo_http_ok() {
|
|
local hdr="$1" endpoint="$2" code
|
|
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
|
|
case "$code" in
|
|
2*) return 0 ;;
|
|
*)
|
|
echo "forge_api: HTTP $code from '$endpoint'" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# --- 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
|
|
# 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.
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--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
|
|
;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
# 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.
|
|
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
|
local current want_pairs ids id name payload
|
|
local want_ids=() missing=()
|
|
# 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 '^$')
|
|
)"
|
|
while IFS=$'\t' read -r name id; do
|
|
[ -n "$name" ] || continue
|
|
want_ids+=("$id")
|
|
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.
|
|
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
|
|
# 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
|
|
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
|
|
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
|
|
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
|
|
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}"
|
|
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
|
|
}
|
|
|
|
forge_pr_list() {
|
|
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
|
}
|
|
|
|
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
|
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
|
# is mapped into the node shape checks_state already parses, so the decision
|
|
# code is untouched.
|
|
forge_pr_view() {
|
|
local n="${1:?forge_pr_view: number required}" pr sha status
|
|
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
|
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
|
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
|
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
|
jq -n --argjson pr "$pr" --argjson st "$status" '
|
|
{
|
|
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
|
elif $pr.mergeable == false then "CONFLICTING"
|
|
else "UNKNOWN" end),
|
|
statusCheckRollup: [
|
|
$st.statuses[]? | {
|
|
__typename: "StatusContext",
|
|
context: .context,
|
|
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
|
|
}
|
|
]
|
|
}'
|
|
}
|
|
|
|
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
|
|
|
# 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.
|
|
forge_label_create() {
|
|
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
|
|
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
|
|
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
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
# 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" \
|
|
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
|
|
}
|
|
|
|
# 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" \
|
|
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
|
|
}
|
|
|
|
# forge_timeline <n> — JSON array of timeline events projected into the
|
|
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
|
|
#
|
|
# | | GitHub | Forgejo |
|
|
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
|
|
# | add vs remove | the two event names | .body "1" / "" |
|
|
# | actor | .actor.login (no .user) | .user.login (no .actor) |
|
|
#
|
|
# Status is captured BEFORE jq so an unreadable read cannot report as an
|
|
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
|
forge_timeline() {
|
|
local n="${1:?forge_timeline: number required}" raw
|
|
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
|
jq '
|
|
[.[]
|
|
| select(.type == "label")
|
|
| {
|
|
event: (if .body == "1" then "labeled" else "unlabeled" end),
|
|
actor: {login: (.user.login // "")},
|
|
label: {name: (.label.name // "")},
|
|
created_at: .created_at
|
|
}
|
|
]
|
|
' <<<"$raw"
|
|
}
|
|
|
|
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
|
|
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
|
|
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
|
|
# with comments_count > 0 are fetched, so a board with none costs zero
|
|
# extra requests.
|
|
forge_pr_activity() {
|
|
local n="${1:?forge_pr_activity: number required}" reviews rid
|
|
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
|
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
|
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
|
|
while IFS= read -r rid; do
|
|
[ -n "$rid" ] || continue
|
|
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
|
|
--jq '.[].created_at' || return 1
|
|
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
|
}
|
|
|
|
# --- the release door's facts (#191) --------------------------------------
|
|
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
|
|
# both distinguish "the read completed and the answer is no" from "the read
|
|
# did not complete" — the distinction lib/facts.sh got wrong before #191,
|
|
# where any failure became a definite `no` and a release ceremony was
|
|
# silently demoted to a bare push.
|
|
#
|
|
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
|
|
#
|
|
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
|
|
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
|
|
#
|
|
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
|
|
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
|
|
# ...on a commit with no PR -> 404 {"message":"pull request
|
|
# does not exist …"}
|
|
#
|
|
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
|
|
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
|
|
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
|
|
# for both forges, which is the whole point of the shim.
|
|
|
|
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
|
|
# status on stdout. Separate from forge_api because these two call sites
|
|
# must SEE a 404 rather than have it collapsed into a failure.
|
|
forgejo_read_code() {
|
|
local endpoint="$1" body="$2" base token hdr rc
|
|
base="$(forgejo_api_base)" || return 1
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
hdr="$(mktemp)"
|
|
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
|
|
rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
rm -f "$hdr"
|
|
echo "forge: GET $endpoint failed to send" >&2
|
|
return 1
|
|
fi
|
|
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
|
|
rm -f "$hdr"
|
|
}
|
|
|
|
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means
|
|
# the read did not complete and the answer is UNKNOWN; the caller must not
|
|
# treat that as `no` (#191).
|
|
forge_release_exists() {
|
|
local tag="${1:?forge_release_exists: tag required}" body code
|
|
body="$(mktemp)"
|
|
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
|
|
rm -f "$body"
|
|
case "$code" in
|
|
2*) echo yes ;;
|
|
404) echo no ;;
|
|
*)
|
|
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
|
|
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
|
|
# found nothing; a non-zero exit is a read that did not complete.
|
|
forge_commit_pulls() {
|
|
local sha="${1:?forge_commit_pulls: sha required}" body code out
|
|
body="$(mktemp)"
|
|
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
|
|
case "$code" in
|
|
2*)
|
|
# One object -> a one-element array, so the call site's jq is the
|
|
# same expression it runs against GitHub.
|
|
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
|
|
rm -f "$body"
|
|
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
|
|
return 1
|
|
fi
|
|
printf '%s\n' "$out"
|
|
;;
|
|
404) printf '[]\n' ;;
|
|
*)
|
|
rm -f "$body"
|
|
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
rm -f "$body"
|
|
}
|
|
|
|
# --- the release door's writes (#191) -------------------------------------
|
|
# Confirmed against this instance's own swagger, 2026-08-04:
|
|
#
|
|
# POST /repos/{o}/{r}/tags -> exists (tag creation)
|
|
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
|
|
# POST /repos/{o}/{r}/releases -> exists
|
|
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
|
|
#
|
|
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
|
|
# /git/refs; Forgejo does not serve POST there at all and creates tags at
|
|
# /tags instead. A 1:1 port of the gh call would 404 forever.
|
|
|
|
# forge_tag_create <tag> <sha>
|
|
forge_tag_create() {
|
|
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
|
|
forgejo_write POST "repos/$REPO/tags" \
|
|
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
|
|
}
|
|
|
|
# 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'
|
|
}
|
|
|
|
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then
|
|
# uploads each asset to the created release. The release id comes back from
|
|
# the create, so no second lookup is needed.
|
|
forge_release_create() {
|
|
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
|
|
local notes="${3:?forge_release_create: notes file required}" out id base token
|
|
shift 3
|
|
out="$(forgejo_write POST "repos/$REPO/releases" \
|
|
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
|
|
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1
|
|
id="$(printf '%s' "$out" | jq -r '.id // empty')"
|
|
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
|
|
[ "$#" -gt 0 ] || return 0
|
|
base="$(forgejo_api_base)" || return 1
|
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
|
local f name
|
|
for f in "$@"; do
|
|
[ -e "$f" ] || continue
|
|
# 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")")"
|
|
curl -sS -f -X POST -H "Authorization: token $token" \
|
|
-F "attachment=@$f" \
|
|
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|
|
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; }
|
|
done
|
|
}
|
|
|
|
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
|
|
# label IDs, not names (the same asymmetry the issue-label writes carry), so
|
|
# the names are resolved first through forgejo_label_ids.
|
|
forge_pr_create() {
|
|
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
|
|
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
|
|
shift 4
|
|
local ids='[]' map name id
|
|
if [ "$#" -gt 0 ]; then
|
|
map="$(forgejo_label_ids)" || return 1
|
|
ids='['
|
|
for name in "$@"; do
|
|
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
|
|
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
|
|
ids="$ids$id,"
|
|
done
|
|
ids="${ids%,}]"
|
|
fi
|
|
forgejo_write POST "repos/$REPO/pulls" \
|
|
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
|
|
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
|
|
}
|