#!/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 printf '%s\n' "${base%/}" } # forgejo_page_url — 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] [--jq ] # # --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 </dev/null)" != array ]; then cat >&2 <&2 < — 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 <&2 < — 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 — nameid 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 [--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 if [ "${#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 [ "${#rm_labels[@]}" -gt 0 ]; then local ids id name ids="$(forgejo_label_ids)" || return 1 for name in "${rm_labels[@]}"; do id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" # A label the repo does not have is not an error: the reconcilers call # --remove-label unconditionally to converge state, and gh's own # behaviour there is a no-op. [ -n "$id" ] || continue forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1 done fi if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then local current want payload current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1 want="$( { printf '%s\n' "$current" [ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}" } | grep -v '^$' | sort -u )" if [ "${#rm_assignees[@]}" -gt 0 ]; then want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)" fi 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 — 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 — 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 — 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 — ask 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 — 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 — 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 — 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 — 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 — the pull requests whose merge produced , 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 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 } # forge_release_create <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 for f in "$@"; do [ -e "$f" ] || continue curl -sS -f -X POST -H "Authorization: token $token" \ -F "attachment=@$f" \ "$base/repos/$REPO/releases/$id/assets?name=$(basename "$f")" >/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 }