Merge pull request 'actions/* + lib/* — one forge abstraction, two backends (#188)' (#189) from build/188-forge-preflight into main

Reviewed-on: heavy-duty/ceremony#189
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
andres 2026-08-04 09:52:22 +00:00
commit 7fc9afe45f
20 changed files with 2319 additions and 105 deletions

View file

@ -15,6 +15,18 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
# actionlint already self-installs below — the same for shellcheck, so a
# green head is reachable once a ceremony runner is online (#188).
- name: Install shellcheck
env:
SHELLCHECK_VERSION: 0.10.0
run: |
curl -fsSLo shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
- name: Shellcheck - name: Shellcheck
run: bash .github/scripts/shellcheck-all.sh run: bash .github/scripts/shellcheck-all.sh
- name: Install actionlint - name: Install actionlint

View file

@ -27,6 +27,10 @@ TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces. # The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh # shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
log() { printf 'issueflow: %s\n' "$*"; } log() { printf 'issueflow: %s\n' "$*"; }
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; } run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
@ -271,12 +275,12 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
ensure_comment() { # $1 issue, $2 marker, $3 message ensure_comment() { # $1 issue, $2 marker, $3 message
local n="$1" marker="$2" message="$3" local n="$1" marker="$2" message="$3"
if issue_comment_has_marker "$n" "$marker"; then return; fi if issue_comment_has_marker "$n" "$marker"; then return; fi
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker --> run forge_issue_comment "$n" "<!-- issueflow:$marker -->
$message" >/dev/null $message" >/dev/null
} }
issue_comment_has_marker() { # $1 issue, $2 marker issue_comment_has_marker() { # $1 issue, $2 marker
gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \ forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
| grep -qF "<!-- issueflow:$2 -->" | grep -qF "<!-- issueflow:$2 -->"
} }
@ -284,7 +288,7 @@ reference_states() {
local ref state local ref state
while IFS= read -r ref; do while IFS= read -r ref; do
[ -n "$ref" ] || continue [ -n "$ref" ] || continue
state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)" state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done done
} }
@ -295,23 +299,23 @@ offsite_pr_states() {
[ -n "$ref" ] || continue [ -n "$ref" ] || continue
repo="${ref%#*}" repo="${ref%#*}"
number="${ref##*#}" number="${ref##*#}"
state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)" state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done done
} }
offsite_timeline() { # unreadable timelines are deliberately silent offsite_timeline() { # unreadable timelines are deliberately silent
gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1 forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
} }
last_issue_activity() { last_issue_activity() {
local n="$1" created="$2" latest local n="$1" created="$2" latest
latest="$({ latest="$({
printf '%s\n' "$created" printf '%s\n' "$created"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
# Assignment is the claim itself. Ignoring it would let an old issue be # Assignment is the claim itself. Ignoring it would let an old issue be
# reclaimed in the seconds between assignment and its required draft PR. # reclaimed in the seconds between assignment and its required draft PR.
gh api --paginate "repos/$REPO/issues/$n/timeline" \ forge_api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "assigned") | .created_at' --jq '.[] | select(.event == "assigned") | .created_at'
} \ } \
| sort | tail -n1)" | sort | tail -n1)"
@ -325,7 +329,7 @@ reconcile_issue() {
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) ADD_NEEDS_TRIAGE)
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null run forge_issue_edit "$n" --add-label needs-triage >/dev/null
log "#$n: needs-triage (no queue state)" ;; log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT) FLAG_CONFLICT)
ensure_comment "$n" queue-conflict \ ensure_comment "$n" queue-conflict \
@ -358,10 +362,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
# would create an impossible parked-for state (#175 D4). # would create an impossible parked-for state (#175 D4).
has_issue_label attention && remove_claimed=claimed,attention has_issue_label attention && remove_claimed=claimed,attention
if [ -n "$owners" ]; then if [ -n "$owners" ]; then
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ run forge_issue_edit "$n" --remove-assignee "$owners" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null --remove-label "$remove_claimed" --add-label post-merge >/dev/null
else else
run gh issue edit "$n" -R "$REPO" \ run forge_issue_edit "$n" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null --remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi fi
log "#$n: merged Refs PR -> post-merge; claim released" log "#$n: merged Refs PR -> post-merge; claim released"
@ -387,10 +391,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.' 'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
if [ -n "$owners" ]; then if [ -n "$owners" ]; then
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \ run forge_issue_edit "$n" --remove-assignee "$owners" \
--remove-label claimed --add-label ready >/dev/null --remove-label claimed --add-label ready >/dev/null
else else
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
fi fi
log "#$n: stale claim reclaimed -> ready" ;; log "#$n: stale claim reclaimed -> ready" ;;
esac esac
@ -429,7 +433,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
READY) READY)
ensure_comment "$n" blockers-cleared \ ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
log "#$n: blockers closed -> ready" ;; log "#$n: blockers closed -> ready" ;;
esac esac
elif has_issue_label epic; then elif has_issue_label epic; then
@ -451,7 +455,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
# An already-applied stale comes off: waiting on a human is legitimately # An already-applied stale comes off: waiting on a human is legitimately
# quiet (#50 D10), and nothing on the issue side ever puts stale back. # quiet (#50 D10), and nothing on the issue side ever puts stale back.
if has_issue_label stale; then if has_issue_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null run forge_issue_edit "$n" --remove-label stale >/dev/null
log "#$n: unstale (a ruling is pending)" log "#$n: unstale (a ruling is pending)"
fi fi
[ -n "${age:-}" ] \ [ -n "${age:-}" ] \
@ -462,11 +466,19 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
reconcile_opened_issue() { reconcile_opened_issue() {
local n="$1" author triage=false labels remove="" label local n="$1" author triage=false labels remove="" label
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
# The stand-downs return 0 explicitly: a bare return carries the failed # The stand-downs return 0 explicitly: a bare return carries the failed
# test's status, which under execution is live `set -e` — and it killed the # test's status, which under execution is live `set -e` — and it killed the
# run on every triage-authored mint, before one issue was reconciled (#91). # run on every triage-authored mint, before one issue was reconciled (#91).
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0 # `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's
# PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue,
# Forgejo emits it on every entry and sets it to null. Measured on the list
# endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of
# 10 did. So the old test selected ZERO issues here and the sweep printed
# "reconciled." over an untouched board: the blind sweep again, one layer in.
# The null test is true for an issue on both forges (absent key reads as
# null) and false for a PR on both.
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")" author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
is_triage_actor "$author" && triage=true is_triage_actor "$author" && triage=true
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
@ -476,56 +488,73 @@ reconcile_opened_issue() {
done done
remove="${remove#,}" remove="${remove#,}"
if [ -n "$remove" ]; then if [ -n "$remove" ]; then
run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
else else
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null run forge_issue_edit "$n" --add-label needs-triage >/dev/null
fi fi
log "#$n: needs-triage (opened by $author)" log "#$n: needs-triage (opened by $author)"
} }
main() { main() {
local owner name # See labels-reconcile's twin (#188). This one already failed loudly on
# Forgejo — but with `line 408: gh: command not found`, which names the
# symptom and not the cause, and only after the sibling step had already
# reported a green blind sweep.
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}" REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_issueflow_config "$LABELS_CONF" load_issueflow_config "$LABELS_CONF"
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}" reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
fi fi
owner="${REPO%%/*}" # owner/name split out here until #188 — the GraphQL query took them as
name="${REPO#*/}" # separate variables. REST takes the owner/name path whole, so it is gone.
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' # Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
query($owner: String!, $name: String!, $endCursor: String) { # API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
repository(owner: $owner, name: $name) { # to the empty string (probe task 278) — so these could not be translated
pullRequests(first: 100, states: OPEN, after: $endCursor) { # to a Forgejo endpoint; there is none. They are REST + a parser this repo
nodes { closingIssuesReferences(first: 100) { nodes { number } } } # owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
pageInfo { hasNextPage endCursor } # the same shape (measured on both, 2026-08-02).
} #
} # Bodies travel base64 because they contain newlines: jq's @tsv escapes a
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ # newline to a literal backslash-n, which a line-oriented parser reads as
| sort -nu)" # one line and silently loses every declaration after the first. The old
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
query($owner: String!, $name: String!, $endCursor: String) { # protection without needing the split to be correct.
repository(owner: $owner, name: $name) { OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
pullRequests(first: 100, states: MERGED, after: $endCursor) { --jq '.[] | .body // "" | @base64' \
nodes { number body } | while IFS= read -r b64; do
pageInfo { hasNextPage endCursor } [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
} done | sort -nu)"
} # closes_references, not refs_references: GitHub's closingIssuesReferences
}' --jq '.data.repository.pullRequests.nodes[] # meant the CLOSING relation specifically, and reading Refs as closing
| .number as $pr | .body | split("\n")[] # would make every referenced issue look closeable — the distinction #151
| [$pr, .] | @tsv' \ # was reopened by hand over.
| while IFS=$'\t' read -r pr body; do MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
| while IFS=$'\t' read -r pr b64; do
[ -n "$b64" ] || continue
while IFS= read -r issue; do while IFS= read -r issue; do
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
done < <(refs_references <<<"$body") done < <(printf '%s' "$b64" | base64 -d | refs_references)
done)" done)"
local n local n
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \
--jq '.[] | select(has("pull_request") | not) | .number'); do --jq '.[] | select(.pull_request == null) | .number'); do
( (
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
reconcile_issue "$n" reconcile_issue "$n"
) || log "#$n: reconcile failed — continuing with the remaining issues" ) || log "#$n: reconcile failed — continuing with the remaining issues"

View file

@ -53,6 +53,8 @@ STALE_AFTER=$((48 * 3600))
# The needs-ruling invariants (#52) — one implementation for both surfaces. # The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh # shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
log() { printf 'labels: %s\n' "$*"; } log() { printf 'labels: %s\n' "$*"; }
@ -185,6 +187,35 @@ set_required_bots() { # the PR author is recused by construction
requested() { grep -qxF "$1" <<<"$REQUESTED"; } requested() { grep -qxF "$1" <<<"$REQUESTED"; }
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch # UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly # leaves behind — distinct from a present-but-empty rollup, which honestly
@ -514,7 +545,7 @@ $(configured_label_rows "$LABELS_CONF")"
fi fi
while IFS='|' read -r name color desc; do while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force run forge_label_create "$name" "$color" "$desc"
done <<<"$rows" done <<<"$rows"
# LABELS.md publishes the defaults as deleted at bootstrap; until #93 # LABELS.md publishes the defaults as deleted at bootstrap; until #93
@ -528,7 +559,7 @@ $(configured_label_rows "$LABELS_CONF")"
# the taxonomy it can create. Either way: log the name, keep going. # the taxonomy it can create. Either way: log the name, keep going.
while IFS= read -r name; do while IFS= read -r name; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
run gh label delete "$name" -R "$REPO" --yes \ run forge_label_delete "$name" \
|| log "retire: '$name' not deleted (already absent, or refused) — continuing" || log "retire: '$name' not deleted (already absent, or refused) — continuing"
done <<<"$(retired_label_names)" done <<<"$(retired_label_names)"
} }
@ -557,10 +588,10 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
# Every failure path prints nothing: the caller treats "could not read" # Every failure path prints nothing: the caller treats "could not read"
# as "not release-shaped" rather than warning on a guess. # as "not release-shaped" rather than warning on a guess.
local ref="$1" ver local ref="$1" ver
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \ ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | tr -d '[:space:]')" | base64 -d 2>/dev/null | tr -d '[:space:]')"
if [ -z "$ver" ]; then if [ -z "$ver" ]; then
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \ ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)" | base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
fi fi
[ -z "$ver" ] || printf '%s\n' "$ver" [ -z "$ver" ] || printf '%s\n' "$ver"
@ -581,7 +612,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# concurrency group in labels.yml. With a comment-only bot on the panel # concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human. # this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent run forge_request_reviewer "$n" "$HUMAN"
log "#$n: requested $HUMAN (round passed)" log "#$n: requested $HUMAN (round passed)"
fi fi
@ -643,7 +674,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}") args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove") [ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy # a deleted label must not wedge the sweep — dispatch heals the taxonomy
@ -666,18 +697,24 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# the moment the PR is no longer the thing a human should merge next, the # the moment the PR is no longer the thing a human should merge next, the
# claim is removed. Setting it stays with whoever owns the queue. # claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then if has_label merge-next && [ "$desired" != state:needs-human ]; then
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null run forge_issue_edit "$n" --remove-label merge-next >/dev/null
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi fi
# ---- stale: real activity only, and blocked is legitimately quiet ---- # ---- stale: real activity only, and blocked is legitimately quiet ----
# forge_pr_activity owns the portable half: issue comments + commits +
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
# Forgejo; the forgejo backend re-derives it from reviews with
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
# stay here — they are already in hand and need no second fetch.
last_activity="$( last_activity="$(
{ {
jq -r '.created_at' <<<"$PR_JSON" jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' # Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' # forge_api names failures loudly, and hiding them re-opens this issue's
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' # silent-green class (#4879 / #101 D5).
forge_pr_activity "$n" || true
} | sort | tail -n1 } | sort | tail -n1
)" )"
last_activity_epoch="$(date -d "$last_activity" +%s)" last_activity_epoch="$(date -d "$last_activity" +%s)"
@ -686,11 +723,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# (#50 D10). The 7-day nudge is #52's, once for both surfaces. # (#50 D10). The 7-day nudge is #52's, once for both surfaces.
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null run forge_issue_edit "$n" --remove-label stale >/dev/null
log "#$n: unstale" log "#$n: unstale"
fi fi
elif ! has_label stale; then elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null run forge_issue_edit "$n" --add-label stale >/dev/null
log "#$n: stale ($((age / 3600))h quiet)" log "#$n: stale ($((age / 3600))h quiet)"
fi fi
@ -705,6 +742,21 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
} }
main() { main() {
# BEFORE anything reads the board (#188). Every call site below is still
# `gh`, so that is what this declares — honestly, which is the point: on
# a Forgejo consumer the preflight refuses here instead of letting the
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}" REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_config "$LABELS_CONF" load_config "$LABELS_CONF"
@ -717,7 +769,7 @@ main() {
# The repo's label set, read ONCE per sweep — reconcile_pr filters every # The repo's label set, read ONCE per sweep — reconcile_pr filters every
# add against it, because one unknown name fails the whole edit call. # add against it, because one unknown name fails the whole edit call.
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS" missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
@ -728,17 +780,20 @@ main() {
status=0 status=0
output="$( output="$(
( (
PR_JSON="$(gh api "repos/$REPO/pulls/$n")" PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
set_required_bots "$AUTHOR" set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")" BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict # PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')" | jq -s '[.[] | select(.state != "PENDING")]')"
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
# mergeability + the check rollup, the two facts the state machine was # mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's # blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while # `mergeable` is a tri-state boolean that GitHub computes lazily, while
@ -753,7 +808,7 @@ main() {
# D2), never left to interleave raw into the per-PR output block, # D2), never left to interleave raw into the per-PR output block,
# where an unlucky line could collide with a matched string. # where an unlucky line could collide with a matched string.
GH_VIEW_ERR_FILE="$(mktemp)" GH_VIEW_ERR_FILE="$(mktemp)"
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')" GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")" GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
rm -f "$GH_VIEW_ERR_FILE" rm -f "$GH_VIEW_ERR_FILE"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
@ -783,7 +838,7 @@ main() {
elif [ "$status" -ne 0 ]; then elif [ "$status" -ne 0 ]; then
log "#$n: reconcile failed — continuing with the remaining PRs" log "#$n: reconcile failed — continuing with the remaining PRs"
fi fi
done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number') done < <(forge_pr_list)
blind_sweep_warning "$unreadable" "$total" "$sampled_reason" blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
log "reconciled." log "reconciled."
} }

View file

@ -6,6 +6,9 @@ else
set -u set -u
fi fi
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# labels-scope.sh — the additive half of the labels automation: derive # labels-scope.sh — the additive half of the labels automation: derive
# scope:* labels from a PR's changed paths and ADD them, touching nothing # scope:* labels from a PR's changed paths and ADD them, touching nothing
# else. This seat belonged to actions/labeler@v5 until #130: even under # else. This seat belonged to actions/labeler@v5 until #130: even under
@ -68,6 +71,14 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
} }
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
# jq 1.7 parses it, which is why this survived: GitHub's hosted
# ubuntu-latest ships 1.7, and the Forgejo runner image
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
# jq compile error before the config was even read.
# yq only normalizes YAML to JSON; the shape contract is enforced in jq, # yq only normalizes YAML to JSON; the shape contract is enforced in jq,
# where an unsupported key is a loud error naming the label it sits under. # where an unsupported key is a loud error naming the label it sits under.
yq -o=json '.' - | jq -r ' yq -o=json '.' - | jq -r '
@ -75,38 +86,38 @@ parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
error("labeler config: top level must be a map of label -> rules") error("labeler config: top level must be a map of label -> rules")
else . end else . end
| to_entries[] | to_entries[]
| .key as $label | .key as $lbl
| (if (.value | type) != "array" then | (if (.value | type) != "array" then
error("labeler config: \($label): rules must be a list") error("labeler config: \($lbl): rules must be a list")
else .value end)[] else .value end)[]
| (if type != "object" then | (if type != "object" then
error("labeler config: \($label): each rule must be a map") error("labeler config: \($lbl): each rule must be a map")
else . end) else . end)
| ((keys - ["changed-files"]) as $extra | ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then | if ($extra | length) > 0 then
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)") error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
else . end) else . end)
| .["changed-files"] | .["changed-files"]
| (if type == "object" then [.] | (if type == "object" then [.]
elif type == "array" then . elif type == "array" then .
else error("labeler config: \($label): changed-files must be a list") end)[] else error("labeler config: \($lbl): changed-files must be a list") end)[]
| (if type != "object" then | (if type != "object" then
error("labeler config: \($label): each changed-files entry must be a map") error("labeler config: \($lbl): each changed-files entry must be a map")
else . end) else . end)
| ((keys - ["any-glob-to-any-file"]) as $extra | ((keys - ["any-glob-to-any-file"]) as $extra
| if ($extra | length) > 0 then | if ($extra | length) > 0 then
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)") error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
else . end) else . end)
| .["any-glob-to-any-file"] | .["any-glob-to-any-file"]
| (if type == "string" then [.] | (if type == "string" then [.]
elif type == "array" then . elif type == "array" then .
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[] else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then | (if type != "string" then
error("labeler config: \($label): globs must be strings") error("labeler config: \($lbl): globs must be strings")
elif contains("\\") then elif contains("\\") then
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)") error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
else . end) else . end)
| [$label, .] | @tsv | [$lbl, .] | @tsv
' '
} }
@ -125,6 +136,21 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
} }
main() { main() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# quietest of the three: an unreadable mapping and an absent one produced
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why.
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}" REPO="${REPO:?set REPO to owner/name}"
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}" PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}" CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
@ -134,13 +160,13 @@ main() {
# No mapping is a consumer that has not adopted scope labels — an # No mapping is a consumer that has not adopted scope labels — an
# advisory no-op, not a red run (scopes locate, they do not alert). A # advisory no-op, not a red run (scopes locate, they do not alert). A
# mapping that EXISTS but does not parse still fails loudly below. # mapping that EXISTS but does not parse still fails loudly below.
if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \ if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then --jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive" log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
return 0 return 0
fi fi
tsv="$(parse_labeler_config <<<"$config")" tsv="$(parse_labeler_config <<<"$config")"
files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')" files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
labels="$(derive_labels "$tsv" "$files")" labels="$(derive_labels "$tsv" "$files")"
if [ -z "$labels" ]; then if [ -z "$labels" ]; then
@ -148,8 +174,8 @@ main() {
return 0 return 0
fi fi
local args=() local args=()
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels" while IFS= read -r label; do args+=("$label"); done <<<"$labels"
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent run forge_labels_add "$PR_NUMBER" "${args[@]}"
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)" log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
} }

56
changelog.d/188.md Normal file
View file

@ -0,0 +1,56 @@
### Added
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
### Changed
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
### Fixed
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).

View file

@ -396,6 +396,13 @@ never before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused. `name|color|description`; blank lines are ignored and extra pipes are refused.
**Every account in `panel=` must be able to read the repository.** Requesting a
review from someone without read access is refused by the forge, not silently
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
(#188). On a public repo this is satisfied already; on a **private** consumer
it is a real failure mode when a panel member is not on the collaborator
list, and the sweep will report it rather than sweep blind.
There are no comment lines: every non-blank line must be the `panel=` There are no comment lines: every non-blank line must be the `panel=`
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
is a parse failure, not a comment (rig #13's conversion found this the hard is a parse failure, not a comment (rig #13's conversion found this the hard

79
lib/closes_references.sh Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# lib/closes_references.sh — "which issues does this PR body close?", parsed
# here rather than asked of a forge (issue #188, term 3).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/version.sh and lib/forge.sh do.
#
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
# it from the other side: a real forgejo-runner job arrives with
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
# So that call site could not be translated to a Forgejo endpoint — there is
# nothing to translate it to. It had to be replaced by a parse this repo
# owns, over a field both forges already return:
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
# /api/v3 and /api/v1 alike (measured on both).
#
# That the replacement is honest is the point. The sibling half of the same
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
# `number` and `body` and ran them through refs_references. GraphQL was
# buying pagination convenience there, nothing semantic. This file makes the
# other half symmetric: one parser this repo controls and can test, for both
# link kinds, on both forges.
#
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
# records closing links attached through the pull request's development
# sidebar, which live in no body and which no body parse can see. This
# family declares its links in the body — that is what BUILDER.md's PR
# template asks for — so the delta is zero in practice here. A consumer that
# links through the sidebar would see those issues go unclosed by the sweep;
# they would need to say so in the body instead.
#
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
# CROSS classifier that keeps rig#112 from ever being read as local #112
# (#61). Bash resolves function calls at call time, so the order of sourcing
# does not matter; both must simply be defined before closes_references runs.
# refs_references depends on it exactly the same way.
# closes_references — PR body on stdin -> local issue numbers this body
# declares it CLOSES, sorted, unique.
#
# The keyword set is GitHub's documented one, all three verbs in all three
# tenses. Matching is case-insensitive because bodies are written by humans
# and agents both ("Closes", "closes", "CLOSES").
#
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
# refs_references owns it, and conflating them would make every referenced
# issue look closeable, which is the post-merge transition #151 had to be
# reopened by hand over.
closes_references() {
awk '
{
line = $0
lower = tolower(line)
# Every occurrence contributes, not just the first: a body that says
# "Closes #1. Closes #2." declares two, and binding to the first
# occurrence dropped the later ones — the same defect #184 fixed in
# blocked_reference_records, kept fixed here by construction.
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
# BOTH cursors advance together. Advancing only `lower` left the
# next match offset indexing the ORIGINAL line, so the second
# declaration on a line came back as garbage — caught by the
# "two closes on one line" case, which is why it is a case.
rest = substr(line, RSTART + RLENGTH)
line = rest
lower = tolower(rest)
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
token = rest
# Stop at the first thing that cannot be part of a reference, so
# "Closes #12, and more prose" yields #12 and not the sentence.
sub(/[^[:alnum:]_.\/#-].*/, "", token)
print token
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}

483
lib/forge-forgejo.sh Normal file
View file

@ -0,0 +1,483 @@
#!/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 <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
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 <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")
}

158
lib/forge-github.sh Normal file
View file

@ -0,0 +1,158 @@
#!/usr/bin/env bash
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
# lib/forge.sh when forge_detect says github; never at the same time as the
# forgejo backend — they define the same verbs on purpose.
#
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
# way to keep that true is for every verb here to be a thin pass-through to
# the `gh` invocation the call site used before the port. No behaviour is
# added, fixed or tidied on this path; anything that looks like an
# improvement here is a regression risk against a forge nobody is currently
# reporting bugs on.
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# The one deliberate difference from a pure pass-through: the caller no
# longer names a page size, because the page-size parameter is not portable
# and is therefore the backend's to own (#188).
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Both answer HTTP 200 either way, so a call site that names one is a silent
# truncation waiting for the other forge. per_page=100 is injected here —
# exactly what the call sites said before — so the GitHub path is unchanged
# in behaviour while the parameter stops being a call-site concern.
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; }
if [ "$paginate" = true ]; then
endpoint="$(github_page_url "$endpoint")"
if [ "$have_jq" = true ]; then
gh api --paginate "$endpoint" --jq "$jqexpr"
else
gh api --paginate "$endpoint"
fi
else
if [ "$have_jq" = true ]; then
gh api "$endpoint" --jq "$jqexpr"
else
gh api "$endpoint"
fi
fi
}
# github_page_url <endpoint> — pure, so the page-size contract is testable
# without a network. Strips any page-size parameter a caller left behind in
# either dialect, then applies GitHub's own.
github_page_url() {
local endpoint="${1:?github_page_url: endpoint required}" clean
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%sper_page=100\n' "$clean" ;;
*\?*) printf '%s&per_page=100\n' "$clean" ;;
*) printf '%s?per_page=100\n' "$clean" ;;
esac
}
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
# Every one of these is the exact `gh` invocation the call site carried
# before the port. Term 5 is kept by making this file boring.
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
# or a PR (gh treats them interchangeably, and so do the call sites).
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
gh issue edit "$n" -R "$REPO" "$@"
}
# forge_issue_comment <n> <body>
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
gh issue comment "$n" -R "$REPO" --body "$body"
}
# forge_pr_list — open PR numbers, one per line. Note this used
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
# URL-parameter strip could have caught, so it moves behind the shim with
# the rest (#188).
forge_pr_list() {
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
}
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
# with the reason on stderr. `gh pr view` rather than the REST PR object:
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}"
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
}
# forge_label_list — every label name in the repo.
forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
}
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}"
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
}
forge_label_delete() {
local name="${1:?}"
gh label delete "$name" -R "$REPO" --yes
}
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
# labeler action computed (labels-at-job-start derived) and PUT the whole
# set, so a label applied while the job ran was silently removed. This is the
# raw POST, which adds the named labels, ignores ones already present, and
# removes nothing — a concurrent label survives by construction.
forge_labels_add() {
local n="${1:?forge_labels_add: number required}" args=() label
shift
for label in "$@"; do args+=(-f "labels[]=$label"); done
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
}
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
}
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
# pass-through: that shape is what the forge already returns (#188 batch).
# Callers must capture the status of THIS function before piping into jq —
# a pipeline's status is the last command's, so `forge_timeline | jq`
# collapses an unreadable timeline into an empty one (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}"
forge_api --paginate "repos/$REPO/issues/$n/timeline"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
# (issue comments, inline review comments, commits). GitHub serves the
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
}

218
lib/forge.sh Normal file
View file

@ -0,0 +1,218 @@
#!/usr/bin/env bash
# lib/forge.sh — one forge abstraction, two backends (issue #188).
#
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
# own shell options, exactly as lib/version.sh does. This file is the
# selector only; the backends live beside it in lib/forge-github.sh and
# lib/forge-forgejo.sh, and nothing here talks to a network.
#
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
# all the way down — 61 runtime call sites, no indirection, no forge check.
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
#
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
# to derive" — the file exists (HTTP 200)
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
# issueflow-reconcile exit 1 "unexpected end of JSON input"
#
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
# own blind-sweep warning (#96) could not fire, because it counts unreadable
# PRs against a list `gh pr list` never produced — and a process
# substitution's failure does not trip set -e, so `total` stayed 0 and the
# sweep called itself reconciled. rig run 979 is the log.
#
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
# prints nothing, exits into the same empty loop) and all three actions go
# green while reading nothing. That is this repo's own doctrine — an
# unreadable rollup reads as "nothing is failing" — being violated by the
# repo that wrote it.
#
# So: the forge is decided ONCE, before any sweep, and a client that cannot
# speak it refuses loudly. Never "probably github".
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
# tell. Order matters and every signal below was measured, not read from
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
# runner's is the control.
#
# The trap that makes this non-obvious: **the Forgejo runner populates the
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
# precisely the bug. What actually differs:
#
# signal GitHub Forgejo (measured)
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
# GITEA_ACTIONS (unset) true
#
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
# forge telling us the two `gh api graphql` sites #188 retired can never
# work here. It is deliberately NOT a detection signal, though: an empty
# variable is also what a hand-rolled harness leaves behind, and a signal
# that fires on absence is a signal that fires by accident.
forge_detect() {
# 1. The explicit override outranks every probe — the escape hatch for a
# forge this file has not met, and the handle the tests drive. A typo
# in it is fatal on purpose: the operator said something and it was
# wrong, and falling through to a probe that guesses right by accident
# would hide that until the guess was wrong too.
if [ -n "${CEREMONY_FORGE:-}" ]; then
case "$CEREMONY_FORGE" in
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
*)
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
return 1
;;
esac
fi
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
# hand-set GITHUB_API_URL might not be, so it is read first.
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
printf 'forgejo\n'
return 0
fi
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
# Enterprise Server alike — GHES is a github backend on a non-github.com
# host, and routing it to the forgejo backend would regress term 5's
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
# serves.
case "${GITHUB_API_URL:-}" in
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
esac
# 4. Last resort, the server host. Only github.com itself is conclusive
# here: a bare hostname says nothing about which API it serves.
case "${GITHUB_SERVER_URL:-}" in
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
esac
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
# reinstates the exact blind sweep this file exists to end. Name what
# was inspected and the escape hatch, so the log answers "why" without
# a second run (#101 D5, one layer up: report, do not diagnose).
cat >&2 <<EOF
forge_detect: cannot determine which forge this is — refusing to guess (#188).
GITHUB_API_URL='${GITHUB_API_URL:-}'
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
EOF
return 1
}
# Where the backends live. Captured at source time, not call time: a
# function that resolves BASH_SOURCE later would resolve its own file, not
# this one.
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# forge_select [forge] — source the backend for this forge, so the forge_*
# verbs exist. Exactly one backend is ever loaded; both define the same
# names, which is what keeps the branching out of the 61 call sites (term 1)
# and the single-forge assumption from growing back.
#
# Idempotent, because the actions call it once and the tests call it per
# case. Pass a forge explicitly to load a specific backend; omit it and the
# environment decides via forge_detect.
forge_select() {
local forge="${1:-}"
if [ -z "$forge" ]; then
forge="$(forge_detect)" || return 1
fi
case "$forge" in
github | forgejo) ;;
*)
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
return 1
;;
esac
# shellcheck source=/dev/null
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
# Read by callers and tests to assert which backend is loaded, so the
# choice is inspectable rather than implied by which functions exist.
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
FORGE="$forge"
}
# forge_client <forge> — print the client that backend requires.
#
# github -> gh the current call set, extracted 1:1 (term 5)
# forgejo -> rest /api/v1 over curl+jq
#
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
# `stoke` on PATH. That second absence is what retired option A from the
# ruling: porting the call sites to the stoke CLI would have put a binary
# on the critical path that the runner does not have and that would need
# installing before every job.
forge_client() {
case "${1:?forge_client: forge required}" in
github) printf 'gh\n' ;;
forgejo) printf 'rest\n' ;;
*)
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
return 1
;;
esac
}
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
# and proves the client can speak it, or exits non-zero with a named reason.
#
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
# how a call site that still hard-codes `gh` announces itself honestly while
# the backends are being ported. Two checks run, in order:
#
# 1. the declaration, when made, must match what this forge needs;
# 2. that client's binaries must actually be on PATH — checked whether or
# not a declaration was made, because a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
forge_preflight() {
local forge want
forge="$(forge_detect)" || return 1
want="$(forge_client "$forge")" || return 1
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
and has no GraphQL surface at all. Pointing one at the other does not
fail usefully — it reads nothing and reports success.
This forge needs the '$want' client.
EOF
return 1
fi
# Then prove the tools are actually here — declared or not. A missing
# binary is the rig failure verbatim, "line 692: gh: command not found",
# and it must be a refusal before the sweep, not a 127 halfway through
# one. Checked on BOTH paths deliberately: a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
local missing_bins=() bin
case "$want" in
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
esac
if [ "${#missing_bins[@]}" -gt 0 ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
Refusing before the sweep: a reconciler that cannot read the board must
not report that it reconciled one.
EOF
return 1
fi
return 0
}

View file

@ -208,13 +208,20 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# skips BOTH checks — the nudge's specified content links the escalation # skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read # comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids. # facts is the exact shape the reconciler's standing rule forbids.
local flags newest setter labeled_at labeled_epoch # forge_timeline projects both forges into the GitHub event shape
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \ # (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling") # status is the last command's, so `forge_timeline | jq` would collapse
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then # an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass" log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0 return 0
fi fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
if [ -z "$flags" ]; then if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline # The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing. # hiccup, or an import). Same treatment as unreadable: do nothing.
@ -230,7 +237,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# the whole file is line-oriented, so the row format stays TSV and the # the whole file is line-oriented, so the row format stays TSV and the
# body is decoded at its points of use (#73). Do not switch rows to JSON. # body is decoded at its points of use (#73). Do not switch rows to JSON.
local comments local comments
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \ if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[] | [.user.login, .created_at, .html_url, --jq '.[] | [.user.login, .created_at, .html_url,
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then ((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass" log "#$n: ruling comments unreadable — no verdict invented this pass"
@ -268,7 +275,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# ---- the bare-flag check (#50 D4, mechanical proxy) ---- # ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER run forge_issue_comment "$n" "$RULING_BARE_MARKER
The ruling flag on this item was set by @$setter with no accompanying The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the **question**, the **options**, and a **recommendation** — posted by the
@ -299,7 +306,7 @@ still owed." >/dev/null
if [ "$shape" != SHAPED ] \ if [ "$shape" != SHAPED ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
local missing="${shape#MALFORMED }" local missing="${shape#MALFORMED }"
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag @$setter — the [escalation comment]($esc_url) accompanying this ruling flag
is missing required field labels: **$missing**. The contract's shape is is missing required field labels: **$missing**. The contract's shape is
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12): fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
@ -327,7 +334,7 @@ enforced." >/dev/null
esac esac
if [ "$rung" = RUNG12 ] \ if [ "$rung" = RUNG12 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's @$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask), 12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
@ -342,7 +349,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null
fi fi
if [ "$rung" = RUNG24 ] \ if [ "$rung" = RUNG24 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's @$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask), 24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
@ -375,7 +382,7 @@ timer." >/dev/null
else else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter." esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi fi
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment. Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.

View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Contract tests for lib/closes_references.sh (issue #188, term 3).
# set -u, not -e: failing commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
# calls it, exactly as refs_references does.
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck source=lib/closes_references.sh
. "$ROOT/lib/closes_references.sh"
# closes <want-newline-separated> <body> — the parse of <body> is exactly
# <want>. Exact, not substring: "12" is contained in "123".
closes() {
local want="$1" body="$2" got
got="$(printf '%s' "$body" | closes_references)"
[ "$got" = "$want" ]
}
# --- the three verbs, the three tenses ----------------------------------
# GitHub's documented keyword set. All of them, because a body that says
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
# transition, not a loud one.
check "closes" 0 "" closes 1 'Closes #1'
check "close" 0 "" closes 1 'Close #1'
check "closed" 0 "" closes 1 'Closed #1'
check "fixes" 0 "" closes 2 'Fixes #2'
check "fix" 0 "" closes 2 'Fix #2'
check "fixed" 0 "" closes 2 'Fixed #2'
check "resolves" 0 "" closes 3 'Resolves #3'
check "resolve" 0 "" closes 3 'Resolve #3'
check "resolved" 0 "" closes 3 'Resolved #3'
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
check "lowercase" 0 "" closes 4 'closes #4'
check "colon form" 0 "" closes 5 'Closes: #5'
# --- Refs is NOT a closing link -----------------------------------------
# The relation this file must not swallow. refs_references owns Refs, and
# conflating them makes every referenced issue look closeable — the
# post-merge transition #151 was reopened by hand over exactly that
# distinction.
check "Refs is not a close" 0 "" closes '' 'Refs #7'
check "Refs and Closes in one body keeps only the close" 0 "" \
closes 8 $'Refs #7\nCloses #8'
# --- cross-repo references stay out (#61) -------------------------------
# rig#112 must never be read as local #112. The classifier is shared with
# refs_references precisely so this rule has one implementation.
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
check "owner-qualified reference is not local" 0 "" \
closes '' 'Closes heavy-duty/rig#112'
check "a local and a cross reference keep only the local" 0 "" \
closes 9 $'Closes rig#112\nCloses #9'
# --- every occurrence contributes ---------------------------------------
# Binding to the first occurrence is the defect #184 fixed in
# blocked_reference_records; this parser must not reintroduce it.
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
# --- prose must not be swallowed ----------------------------------------
check "trailing prose is not part of the reference" 0 "" \
closes 12 'Closes #12, and adds the guard'
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
# "unclosed" contains "close" — a naive word match would fire on it.
check "a word merely containing a verb does not fire" 0 "" \
closes '' 'This left #15 unclosed'
# --- the shapes a real PR body carries ----------------------------------
check "the template's leading declaration" 0 "" \
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
check "an empty body yields nothing" 0 "" closes '' ''
summary

View file

@ -10,6 +10,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh" . "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are simply undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
FACTS="$ROOT/lib/facts.sh" FACTS="$ROOT/lib/facts.sh"

619
test/forge-backends.test.sh Normal file
View file

@ -0,0 +1,619 @@
#!/usr/bin/env bash
# Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh
# (issue #188, term 1). set -u, not -e.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# --- forge_select: exactly one backend, chosen deliberately -------------
check "select github loads the github backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null'
check "select forgejo loads the forgejo backend" 0 "" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null'
check "select refuses an unknown forge" 1 "unknown forge" \
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab'
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
check "select with no argument reads the environment" 0 "" \
bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]'
# --- the page-size contract, both dialects ------------------------------
# The trap, measured 2026-08-02: each forge silently ignores the OTHER's
# page-size parameter and answers HTTP 200 with fewer items.
#
# ?per_page=100 GitHub 100 Forgejo 30 (ignored)
# ?limit=100 GitHub 30 Forgejo 50 (capped)
#
# So no call site names one, and these two functions are the only places
# that decide. Pure on purpose: the contract is testable without a network.
. "$ROOT/lib/forge-github.sh"
. "$ROOT/lib/forge-forgejo.sh"
check "github: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
check "github: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open'
check "forgejo: a bare path gets a query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1
check "forgejo: an existing query is preserved" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2
# A caller that names a page size anyway must not be able to reintroduce the
# truncation — the parameter is stripped in BOTH dialects, on both backends,
# because the whole point is that the boundary decides and the call site
# cannot override it by accident.
check "github strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30'
check "github strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100'
check "forgejo strips a stray per_page" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1
check "forgejo strips a stray limit" 0 "" \
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1
check "stripping the only parameter leaves a clean query" 0 "" \
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1
# --- the forgejo gather: complete, or loudly refused --------------------
# curl is stubbed as a function so these are hermetic. Each case writes the
# headers and body a real Forgejo would.
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
# successive page bodies, declaring <total-spec> in x-total-count. An empty
# string omits the header entirely (@kimi's #4699 case). A comma-separated
# spec declares a DIFFERENT total per page ("4,9"), which is
# @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712):
# a server whose count moves under the walk cannot have been read whole.
fake_forge() {
FAKE_TOTAL="$1"; shift
FAKE_PAGES=("$@")
FAKE_CALLS=0
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
local page=1
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
local total="$FAKE_TOTAL"
case "$FAKE_TOTAL" in
*,*)
total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")"
[ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)"
;;
esac
{
printf 'HTTP/1.1 200 OK\r\n'
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
printf '\r\n'
} >"$hdr"
if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then
printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out"
else
printf '[]' >"$out"
fi
FAKE_CALLS=$((FAKE_CALLS + 1))
return 0
}
}
export CEREMONY_FORGE_API=https://forge.example/api/v1
# One page, and the count agrees with the declared total.
fake_forge 2 '[{"number":1},{"number":2}]'
check "a complete single-page gather returns its items" 0 "" \
eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# Two pages that add up. The walk must not stop at the first page merely
# because it came back non-empty — rig has 137 issues across 3 pages, which
# is the case this models.
fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a multi-page gather walks every page" 0 "" \
eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The whole reason the assert exists: a server that declares more than it
# hands over must not produce a "successful" partial sweep.
fake_forge 137 '[{"number":1},{"number":2}]'
check "a short gather is refused, not reconciled" 1 "incomplete gather" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal names both counts" 1 "collected 2 of 137" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able
# to degrade silently either. A Forgejo that does not expose x-total-count
# leaves the assert with nothing to compare, and an assert that cannot run
# must refuse rather than pass.
fake_forge '' '[{"number":1},{"number":2}]'
check "a missing x-total-count refuses" 1 "did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which
# an unprovable read could still have been reported as a whole one — the
# guard leaking the failure class it was built to stop, which is why they
# are refusals rather than warnings.
# A total that is not a number went straight into arithmetic. Reproduced on
# ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as
# the total.
fake_forge 'not-a-number' '[{"number":1}]'
check "a non-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
check "...and the refusal quotes what arrived" 1 "not-a-number" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '12x' '[{"number":1}]'
check "a partly-numeric total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge '-3' '[{"number":1}]'
check "a negative total is refused" 1 "not a non-negative integer" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A total that MOVES under the walk. The loop read it once, so a board
# changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and
# the walk stopped at 4 believing itself complete.
fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
check "a total that changes between pages is refused" 1 "changed between pages" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# The distinguishing text, not a substring that survives losing half the
# message: "4" alone stayed green if the later total vanished, which is what
# @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734)
# both caught. A test named "names BOTH totals" must fail when one goes.
check "...and the refusal names both totals" 1 "4 then 9" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A 200 whose body is not a collection. `length` on a non-array counted 0,
# so an object or a scalar arriving where a list belongs read as a complete
# EMPTY collection when the declared total was 0 — silence dressed as a
# clean sweep.
fake_forge 0 '{"message":"Not found"}'
check "a non-array body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
fake_forge 0 '"a string"'
check "a scalar body is refused" 1 "did not return a collection" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# A genuinely empty collection is still fine — the refusal must not fire on
# a repo that legitimately has nothing.
fake_forge 0 '[]'
check "an empty collection is not an error" 0 "" \
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
# --- HTTP failures are named, not swallowed -----------------------------
# gh exits non-zero on an HTTP error; curl does not without -f, and -f
# discards the body that explains why. So the status is read explicitly.
fake_forge 1 '[{"number":1}]'
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac
shift
done
printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr"
printf '{"message":"Not found"}' >"$out"
return 0
}
check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999'
check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999'
# --- forge_issue_edit: a typo must not become a green no-op --------------
# @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is
# given to `gh`, which fails on a flag it does not know. Dropping it here
# instead turned a mis-typed port site into a mutation that silently did not
# happen — this issue's own failure class, arriving inside the fix for it.
check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value
check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value
check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label
# --- forge_label_create: an upsert, like gh's --force --------------------
# bootstrap_labels creates every declared label on EVERY workflow_dispatch,
# so a plain POST onto an existing name aborts the bootstrap under set -e
# from the second dispatch onward (#4743).
WRITES="$TMP/writes"
stub_writes() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;;
-o) out="$2"; shift ;;
-X) method="$2"; shift ;;
-d) payload="$2"; shift ;;
-H) shift ;;
-*) ;;
*) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
case "$url" in
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
*) printf '{}' >"$out" ;;
esac
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
# The label does not exist yet -> POST (create).
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue'
check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
# The label already exists -> PATCH (update), which is what --force does.
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text'
check "recreating an existing label PATCHes it" 0 "" \
grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES"
check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
# they keep working, and pin the SHAPE of the requests.
# Removal resolves name -> id, because Forgejo takes names on add and only a
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
# DELETE .../labels/149 -> 204.
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
# A label the repo does not have is a no-op, matching gh: the reconcilers
# call --remove-label unconditionally to converge state.
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
# Adding takes names directly — no lookup, one request.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
# removal is a read-modify-write, and a naive translation would have cleared
# every OTHER assignee as a side effect of removing one.
assignee_stub() {
: >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
curl() {
local hdr="" out="" method=GET url="" payload=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift ;; -o) out="$2"; shift ;;
-X) method="$2"; shift ;; -d) payload="$2"; shift ;;
-H) shift ;; -*) ;; *) url="$1" ;;
esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr"
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
return 0
}
}
assignee_stub
REPO=o/r forge_issue_edit 5 --remove-assignee alice
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
assignee_stub
REPO=o/r forge_issue_edit 5 --add-assignee carol
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
# --- forge_labels_add / forge_request_reviewer, both backends ------------
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
# call-site port and had no boundary pins of their own.
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
# The labeler action computed (labels-at-job-start union derived) and PUT the
# whole set, so a label applied while the job ran was silently removed —
# ceremony#128 lost its `release` label, the merge door's declared-intent
# read, two seconds after the builder set it. This write must therefore be an
# ADDITIVE POST and must never read-modify-write.
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
REPO=o/r forge_labels_add 7 scope:docs scope:cli
check "labels_add POSTs to the issue labels collection" 0 "" \
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
check "...carrying every name in one request" 0 "" \
grep -q '"scope:docs","scope:cli"' "$WRITES"
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
check "...exactly one write, so nothing is read-modify-written" 0 "" \
test "$(wc -l <"$WRITES")" -eq 1
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
# The reviewer payload shape. Measured against this instance: the endpoint
# serves post and delete only, and takes {"reviewers":[...]}.
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
check "request_reviewer POSTs to requested_reviewers" 0 "" \
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
check "...with the reviewers array payload" 0 "" \
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
# by the command it builds rather than by an HTTP shape.
gh_calls="$TMP/ghcalls"
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
# A subshell so the github backend does not stay loaded over the forgejo
# cases below; REPO is deliberately scoped to it for the same reason.
(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_labels_add 7 scope:docs scope:cli
forge_request_reviewer 9 danmt
)
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
check "...and never routes through issue edit --add-label" 1 "" \
grep -q 'issue edit' "$gh_calls"
check "github request_reviewer posts the reviewer" 0 "" \
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
# --- term-5 pins for the batch verbs (codex 1566) -------------------------
# The forgejo twins have hermetic coverage below; these pin that the github
# twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites.
: >"$gh_calls"
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
gh() {
printf '%s\n' "$*" >>"$gh_calls"
if [ "$1" = api ]; then
shift
local jqexpr="" endpoint=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
--paginate) ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
local body='[]'
case "$endpoint" in
*'/issues/'*'/timeline'*)
body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]'
;;
*'/issues/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/comments'*)
body='[{"created_at":"2026-08-01T10:30:00Z"}]'
;;
*'/pulls/'*'/commits'*)
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
esac
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi
return 0
fi
return 0
}
gh_tl="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_timeline 42
)"
check "github forge_timeline paginates the issue timeline endpoint" 0 "" \
grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls"
check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \
jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null
# The NEGATIVE half of "pass-through". A positive pin still passes if the
# github path GAINS forgejo behaviour, and term 5 is a statement about what
# must NOT change — so the drift these two catch is the only kind a
# 1:1-extraction path realistically suffers.
#
# The GitHub timeline already IS the shape ruling.sh selects on, so a
# projection here would be a second, divergent normalizer maintained by
# nobody.
check "...and never reshapes it, as the forgejo backend must" 1 "" \
grep -q 'issues/42/timeline.*--jq' "$gh_calls"
: >"$gh_calls"
gh_act="$(
forge_select github
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
REPO=o/r
forge_pr_activity 9 | sort
)"
check "github forge_pr_activity hits issue comments" 0 "" \
grep -q 'repos/o/r/issues/9/comments' "$gh_calls"
check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \
grep -q 'repos/o/r/pulls/9/comments' "$gh_calls"
check "github forge_pr_activity hits commits" 0 "" \
grep -q 'repos/o/r/pulls/9/commits' "$gh_calls"
check "github forge_pr_activity emits all three timestamp sources" 0 "" \
test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')"
# The forgejo backend derives inline comments from reviews with
# comments_count > 0, because the flat endpoint 404s there. That derivation
# must never appear on this path: GitHub serves the flat endpoint, and a
# github twin quietly adopting the workaround is the exact "both backends
# drift together" failure term 5 forbids.
check "...never deriving them from reviews, as forgejo must" 1 "" \
grep -q 'pulls/[0-9]*/reviews' "$gh_calls"
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_view: newest verdict per context must win ------------------
# checks_state groups repeated contexts and selects the newest by
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
# the winner to incidental array order, so a stale re-run could outrank the
# live one (#4743). Forgejo's combined status carries created_at/updated_at
# — measured on this instance, where Actions DO land as commit statuses
# (rig main: "ci / check (push)" success, with created_at).
pr_view_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
case "$url" in
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
esac
return 0
}
}
# The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
# The real proof: feed it to the production classifier and confirm the newer
# SUCCESS wins over the older FAILURE regardless of array order.
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
classified="$(checks_state <<<"$view_json")"
check "the newest verdict per context wins, not the array order" 0 "" \
test "$classified" = SUCCESS
# --- the api base must be known -----------------------------------------
check "no api base refuses" 1 "cannot reach the forge" \
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
return 0
}
}
FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
]'
FAKE_TL_N=3
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline projects body=1 to labeled" 0 "" \
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317
curl() { return 22; }
tl_unreadable() { REPO=o/r forge_timeline 188; }
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
# Only reviews with comments_count > 0 are fetched (#4844).
activity_calls="$TMP/activity_calls"
: >"$activity_calls"
activity_stub() {
# shellcheck disable=SC2317
curl() {
local hdr="" out="" url="" total=1 body='[]'
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf '%s\n' "$url" >>"$activity_calls"
case "$url" in
*'/issues/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/commits'*)
total=1
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
*'/reviews/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
;;
*'/pulls/'*'/reviews'*)
total=2
body="$FAKE_REVIEWS"
;;
*) total=0; body='[]' ;;
esac
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
printf '%s' "$body" >"$out"
return 0
}
}
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
activity_stub
: >"$activity_calls"
act="$(REPO=o/r forge_pr_activity 9 | sort)"
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
check "forge_pr_activity emits commit timestamps" 0 "" \
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
grep -q '/reviews/7/comments' "$activity_calls"
check "...and never fetches a zero-comment review" 1 "" \
grep -q '/reviews/8/comments' "$activity_calls"
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
summary

175
test/forge.test.sh Normal file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
# NO forge clients on it — that is the condition under test. It cannot be a
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
# and the heredoc refusals use `cat`.
mkdir -p "$TMP/empty"
for _t in bash cat sed awk tr printf; do
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
done
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
# substring match cannot prove "forgejo" was not printed as "forgejox".
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# detect_in <env-assignments…> — run forge_detect in a clean environment
# carrying only the named vars, so a leaked GITHUB_* from the CI running
# THIS suite cannot decide the answer. Every case below is hermetic.
detect_in() {
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
}
# A PATH carrying stub binaries for every client the preflight can require.
# Without this the "passes" cases depend on whatever the HOST happens to have,
# which is not hermetic and is wrong in the only place it matters: the Forgejo
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
# "github + gh passes" failed there while passing on a developer box. Measured
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
# exists to find.
STUBBIN="$TMP/bin"
mkdir -p "$STUBBIN"
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
preflight_in() {
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# ...and one with NO clients at all, for the missing-binary refusal.
preflight_bare() {
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# --- forge_detect: the explicit override --------------------------------
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
# whose env this file has not met yet, and the handle the tests below use
# to drive the backends without a live instance.
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
check "override refuses an unknown forge" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab
# A typo must not silently fall through to a probe that guesses right by
# accident: the operator said something, and it was wrong.
check "override outranks the env" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
# those URLs point:
#
# GitHub GITHUB_API_URL=https://api.github.com
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
#
# That is the whole bug this issue exists for, in one variable: gh speaks
# /api/v3 against api.github.com, and neither half is true here.
check "api url: api.github.com is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://api.github.com
check "api url: /api/v1 is forgejo" 0 "" \
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
# it is a github backend on a non-github.com host. Getting this wrong would
# route a GHES consumer to the forgejo backend and break term 5.
check "api url: GHES /api/v3 is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
# which GitHub never sets. It is checked BEFORE the URL shape because it is
# unambiguous where a hand-set GITHUB_API_URL might not be.
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
check "gitea marker outranks a github-shaped api url" 0 "" \
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
# --- forge_detect: refusing to guess ------------------------------------
# Nothing to read is NOT "probably github". A wrong guess here is exactly
# the silent blind sweep #188 measured; the whole point of this file is
# that an unknown forge is loud.
check "bare environment refuses" 1 "cannot determine which forge" detect_in
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
# --- forge_preflight: the must-fail case --------------------------------
# The Test plan's named must-fail: "point it at a Forgejo instance with a
# GitHub-shaped client and assert it refuses loudly rather than sweeping
# blind."
#
# Measured before this guard existed, against this instance:
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
# labels-reconcile exit 0 "reconciled." (zero PRs read)
# issueflow-reconcile exit 1 "unexpected end of JSON input"
# Two of three swept blind and reported success. gh present made it WORSE:
# it silenced the one loud failure. Hence: refuse before the sweep, not
# after — and say which forge and which client, so the log answers "why"
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
check "forgejo + gh-only client refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
check "the refusal names the forge" 1 "forgejo" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The interpolated client, not the bare string "gh" — which also appears in
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
# stayed green even if the client name never reached the message. Same class
# as the "names both totals" weakness the panel caught in the backend suite
# (#4727 / #4734); found by auditing this file for the same shape.
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The refusal must be actionable, not merely loud: #188's whole cost was a
# red check that told nobody what to do.
check "the refusal names the issue" 1 "#188" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# --- forge_preflight: the passing pairs ---------------------------------
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
# The mirror of the must-fail: a Forgejo client against GitHub is just as
# wrong, and symmetric refusal is cheaper than explaining why only one
# direction is checked.
check "github + rest refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
# --- forge_preflight: it refuses when the forge itself is unknown --------
# Detection failure must not be swallowed into a pass — that would restore
# the blind sweep through the back door.
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
# --- forge_client: what each backend actually needs ----------------------
# Measured in the runner image the Forgejo instance actually uses
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
# necessity, not preference — this is the measurement that retired option
# A (port to stoke) as well: the CLI is not on the runner either.
check "github backend wants gh" 0 "" eq gh forge_client github
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
# The missing-binary arm, hermetically: an empty PATH has no client at all.
check "a forge whose client is not installed refuses" 1 "is not installed" \
preflight_bare CEREMONY_FORGE=github
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
summary

View file

@ -34,3 +34,13 @@ summary() {
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]
} }
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
# injects (#188) so a fixture keyed on the logical endpoint still matches.
# The page size moved OUT of the call sites and into the backend, which means
# every stub now sees "?per_page=100" appended to a paginated read; without
# this, a fixture lookup misses and the stub answers "unreadable", which the
# production code correctly reports as a degraded read.
forge_stub_path() {
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
}

View file

@ -4,6 +4,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh" source "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh # shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
@ -182,7 +188,7 @@ chmod +x "$TMP/gh"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process # shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "cross-repo warning is idempotent across two sweeps" 0 "" \ check "cross-repo warning is idempotent across two sweeps" 0 "" \
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \ env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
'source "$1"; REPO=heavy-duty/ceremony 'source "$1"; forge_select github; REPO=heavy-duty/ceremony
ensure_comment 99 blocked-cross-repo "cross-repo warning" ensure_comment 99 blocked-cross-repo "cross-repo warning"
ensure_comment 99 blocked-cross-repo "cross-repo warning" ensure_comment 99 blocked-cross-repo "cross-repo warning"
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \ test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
@ -234,6 +240,7 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \
INOW=2000000000 INOW=2000000000
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
issue_stub_gh() { issue_stub_gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -246,6 +253,7 @@ issue_stub_gh() {
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
printf '%s\n' "$endpoint" >>"$TMP/api-calls" printf '%s\n' "$endpoint" >>"$TMP/api-calls"
[ ! -f "$file.error" ] || return 1 [ ! -f "$file.error" ] || return 1
@ -286,6 +294,7 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $
MERGED_REF_PR_RECORDS="" MERGED_REF_PR_RECORDS=""
fi fi
run() { "$@"; } run() { "$@"; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { issue_stub_gh "$@"; } gh() { issue_stub_gh "$@"; }
reconcile_issue "$1" 2>&1 reconcile_issue "$1" 2>&1
) )
@ -531,6 +540,7 @@ jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \ jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
>"$(cfix 24)" >"$(cfix 24)"
# shellcheck disable=SC2317 # reached through the forge backend (#188)
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
check "last activity ignores the 2-day-old label churn" 0 "" \ check "last activity ignores the 2-day-old label churn" 0 "" \
@ -566,6 +576,11 @@ if [ "$1" = api ]; then
esac esac
shift shift
done done
# Inlined, not the suite's helper: this stub is a standalone executable on
# PATH and cannot see a shell function from the test process. Strips the
# paging the forge shim injects so fixtures stay keyed on the logical
# endpoint (#188).
endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json" file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
[ ! -f "$file.error" ] || exit 1 [ ! -f "$file.error" ] || exit 1
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi
@ -577,13 +592,22 @@ echo "gh stub: unexpected call: gh $*" >&2
exit 97 exit 97
EOF EOF
chmod +x "$ARRIVAL/stub/gh" chmod +x "$ARRIVAL/stub/gh"
printf '%s\n' \ # The two PR gathers were GraphQL until #188; they are REST now, so the
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ # fixtures are the /pulls list both forges return. Empty by default — the
>"$ARRIVAL/fixtures/graphql.json" # merged-Refs case below fills the closed one.
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; } arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
# CEREMONY_FORGE=github below, and at the executable-sweep driver further
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering
# /api/v3 shapes), so the suite says so at the forge boundary rather than
# letting main()'s preflight infer a forge from whatever env the CI job
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
# boundary this issue moved.
arrival_run() { arrival_run() {
: >"$ARRIVAL/fixtures/edits" : >"$ARRIVAL/fixtures/edits"
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
@ -619,13 +643,17 @@ check "...and the sweep still runs" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$pr_out" grep -qF 'issueflow: reconciled.' <<<"$pr_out"
# The merged-Refs transition must survive the executable's set -e path too. # The merged-Refs transition must survive the executable's set -e path too.
# Keep this at main() granularity: the GraphQL gather and loop are the code # Keep this at main() granularity: the PR gather and loop are the code a
# a sourced decision probe cannot exercise (#91's lesson). # sourced decision probe cannot exercise (#91's lesson).
#
# merged_at is what makes this PR merged rather than merely closed — the
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
# return the field, and both return null on a closed-unmerged PR.
printf '%s\n' \ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
>"$ARRIVAL/fixtures/graphql.json" >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[{"number":40}]\n' \ printf '[{"number":40}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json" >"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
jq -n --arg at "$(iso_at "$INOW")" \ jq -n --arg at "$(iso_at "$INOW")" \
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \ '{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json" >"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
@ -633,18 +661,95 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
: >"$ARRIVAL/fixtures/edits" : >"$ARRIVAL/fixtures/edits"
subprocess_out="$( subprocess_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)" )"
subprocess_rc=$? subprocess_rc=$?
check "executable sweep transitions merged Refs work" 0 "" \ check "executable sweep transitions merged Refs work" 0 "" \
test "$subprocess_rc" -eq 0 test "$subprocess_rc" -eq 0
check "...reaches the transition through GraphQL and the issue loop" 0 "" \ check "...reaches the transition through the REST gather and the issue loop" 0 "" \
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out" grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
check "...and performs the release edit from the executable path" 0 "" \ check "...and performs the release edit from the executable path" 0 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \ grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
"$ARRIVAL/fixtures/edits" "$ARRIVAL/fixtures/edits"
# -- the issue/PR discriminator is not `has("pull_request")` ---------------
# Measured on the two list endpoints, 2026-08-02 (#188):
#
# GitHub plain issues OMIT the key -> 0 of 9 carried it
# Forgejo every entry HAS the key -> 10 of 10, valued null on issues
#
# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and
# the sweep printed "reconciled." over an untouched board — the blind sweep
# again, one layer in, and invisible because the log looks identical to a
# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live
# board, not by any unit test. `.pull_request == null` is true for an issue on
# both forges (an absent key reads as null) and false for a PR on both.
disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; }
check "github-shaped issue (key absent) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "a PR reads as a PR on either shape" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
# The old test, kept as the must-fail: it disagrees with the new one on the
# forgejo shape, which is exactly the bug.
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
# -- the OPEN-pull gather, at main() granularity ----------------------------
# The closed/merged half above proves one REST path; this proves the other,
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
# test/closes_references.test.sh cannot reach it — they test the parser, not
# the encoding and wiring around it (#188).
#
# Both directions in ONE sweep, so neither assertion can pass vacuously:
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
# #51 is closed by nothing -> the claim is RECLAIMED
# A break anywhere in the pipeline reclaims #50 too, and the first check
# fails. A break that reclaims nothing fails the second.
#
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
# escapes a newline to a literal backslash-n, so a line-oriented parser
# reading an @tsv-encoded body sees one line and drops everything after the
# first — with the declaration on line 3, that defect reclaims #50 and this
# case goes red. On line 1 it would pass either way, which is the definition
# of a vacuous test.
printf '%s\n' \
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
printf '[{"number":50},{"number":51}]\n' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
# Both claims are two hours quiet against a ONE-hour stale bound, so the
# reclaim clock has genuinely expired for whichever of them no open PR
# rescues. The clock is injected rather than real: INOW is a fixed epoch in
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
# dates these claims in the future, and both survive on a negative age —
# which is a green test proving nothing.
for n in 50 51; do
jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json"
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json"
done
: >"$ARRIVAL/fixtures/edits"
open_pr_out="$(
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
)"
check "the open-pull gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<<"$open_pr_out"
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
"$ARRIVAL/fixtures/edits"
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on # D2 preserved: only the deliberate stand-downs changed; a genuine failure on
# the arrival path still kills the run loudly. # the arrival path still kills the run loudly.
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error" : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"

View file

@ -13,6 +13,19 @@ export LC_ALL=C
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh # shellcheck source=actions/labels-reconcile/labels-reconcile.sh
. actions/labels-reconcile/labels-reconcile.sh . actions/labels-reconcile/labels-reconcile.sh
# This suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_label_delete/forge_issue_comment/... resolve to the gh
# invocations those stubs already intercept (#188). main() selects a backend
# itself, but these probes call the pure functions directly, so the suite has
# to say which forge it is standing in.
forge_select github
# This suite predates test/harness.sh and carries its own expect(), so it does
# not get harness.sh's helper — define it here rather than pulling in a second
# assertion vocabulary. Strips the paging the shim injects so a fixture keyed
# on the logical endpoint still matches (#188).
forge_stub_path() {
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
}
load_config .github/labels.conf load_config .github/labels.conf
set_required_bots codex-bot-andresmgsl set_required_bots codex-bot-andresmgsl
@ -512,6 +525,7 @@ reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr em
MERGEABLE=MERGEABLE CHECKS=SUCCESS MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network gh() { :; } # no network
reconcile_pr 777 2>&1 reconcile_pr 777 2>&1
) )
@ -610,6 +624,7 @@ ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits
MERGEABLE=MERGEABLE CHECKS=SUCCESS MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
run() { :; } # swallow mutations run() { :; } # swallow mutations
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { :; } # no network gh() { :; } # no network
reconcile_pr 888 2>&1 reconcile_pr 888 2>&1
) )
@ -659,6 +674,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
MERGEABLE=MERGEABLE CHECKS=SUCCESS MERGEABLE=MERGEABLE CHECKS=SUCCESS
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -671,6 +687,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
# A missing fixture is an empty collection — projected through the # A missing fixture is an empty collection — projected through the
# caller's --jq exactly like real gh, so '.[].foo' yields no lines. # caller's --jq exactly like real gh, so '.[].foo' yields no lines.
@ -738,6 +755,10 @@ blind_main_probe() {
GITHUB_EVENT_NAME=schedule GITHUB_EVENT_NAME=schedule
REPO=owner/repo REPO=owner/repo
LABELS_CONF=.github/labels.conf LABELS_CONF=.github/labels.conf
# This probe IS a GitHub board — say so at the forge boundary rather
# than leaving main()'s preflight to infer one (#188).
CEREMONY_FORGE=github
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = label ] && [ "$2" = list ]; then if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1 core_label_rows | cut -d'|' -f1
@ -821,10 +842,10 @@ expected_upserts="$({ core_label_rows; configured_label_rows .github/labels.conf
) )
expect "a dispatch deletes the six in the same run as the upserts" \ expect "a dispatch deletes the six in the same run as the upserts" \
"$RETIRED_WANT" \ "$RETIRED_WANT" \
"$(sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' "$BOOT/happy")" "$(sed -n 's/^forge_label_delete \(.*\)$/\1/p' "$BOOT/happy")"
expect "...and the recorded upsert set is unchanged from today's" \ expect "...and the recorded upsert set is unchanged from today's" \
"$expected_upserts" \ "$expected_upserts" \
"$(sed -n 's/^gh label create \([^ ]*\) .*/\1/p' "$BOOT/happy")" "$(sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
# -- a missing label is success: gh exits non-zero with not-found, and the # -- a missing label is success: gh exits non-zero with not-found, and the
# guard keeps that from aborting the dispatch. Red without the guard. # guard keeps that from aborting the dispatch. Red without the guard.
@ -895,7 +916,7 @@ boot_dry_probe() {
} }
dry_out="$(boot_dry_probe)" dry_out="$(boot_dry_probe)"
expect "DRY_RUN narrates each deletion" \ expect "DRY_RUN narrates each deletion" \
6 "$(grep -c '^labels: DRY_RUN: gh label delete' <<<"$dry_out")" 6 "$(grep -c '^labels: DRY_RUN: forge_label_delete' <<<"$dry_out")"
expect "...and performs none" \ expect "...and performs none" \
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)" no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
@ -922,6 +943,7 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
exec_env() { # $1 = event name → the real script, executed under the PATH stub exec_env() { # $1 = event name → the real script, executed under the PATH stub
: >"$EXEC/record" : >"$EXEC/record"
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \ env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
CEREMONY_FORGE=github \
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \ REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
bash actions/labels-reconcile/labels-reconcile.sh bash actions/labels-reconcile/labels-reconcile.sh
} }
@ -946,5 +968,51 @@ for ev in schedule pull_request_target; do
expect "...and deletes nothing" \ expect "...and deletes nothing" \
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
done done
# ---------------------------------------------------------------------------
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
#
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does.
# Measured 2026-08-02: rig!140 listed all three panelists with all three
# verdicts in, and rig!146 still lists three while MERGED. Read raw on
# Forgejo, that pins a PR at state:bots-reviewing for life and stops
# blocker:unrequested from ever being true.
# ---------------------------------------------------------------------------
HEAD_SHA=head1
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")"
expect "a head-current approval is no longer outstanding" "" \
"$(outstanding_requests "$BOT1")"
expect "a blocking verdict is not outstanding either — it is answered" "" \
"$(outstanding_requests "$BOT2")"
# The one that matters: an approval of an OLDER head is not a verdict on this
# head, so that reviewer still owes one. Treating STALE as answered would let
# a stale round read as complete.
expect "a stale approval still owes a verdict" "$BOT3" \
"$(outstanding_requests "$BOT3")"
expect "a reviewer who never reviewed still owes one" "nobody" \
"$(outstanding_requests "nobody")"
# The Forgejo shape, end to end: the field lists all three long after every
# verdict landed. Only the stale one may survive the filter.
expect "the never-cleared forgejo field collapses to who actually owes" \
"$BOT3" "$(outstanding_requests "$BOT1
$BOT2
$BOT3")"
# The GitHub shape: the field is already accurate, so the filter is a no-op
# on the set GitHub would have produced (term 5 — behaviour unchanged).
expect "on a github-shaped field the filter removes nothing" "nobody" \
"$(outstanding_requests "nobody")"
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
# The summary and the gate belong at the TRUE end of the file. They sat in the
# middle until #188: eight outstanding_requests expects were appended after
# them, so a failure there printed FAIL, was left out of the totals, and the
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
# appended below this line is ungated — so nothing goes below it.
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

View file

@ -11,6 +11,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh" . "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are simply undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
FACTS="$ROOT/lib/facts.sh" FACTS="$ROOT/lib/facts.sh"
DECIDE="$ROOT/lib/decide.sh" DECIDE="$ROOT/lib/decide.sh"

View file

@ -4,6 +4,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh" source "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
# shellcheck source=lib/ruling.sh # shellcheck source=lib/ruling.sh
source "$ROOT/lib/ruling.sh" source "$ROOT/lib/ruling.sh"
@ -123,6 +129,7 @@ run() { "$@"; }
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
gh() { gh() {
if [ "$1" = api ]; then if [ "$1" = api ]; then
shift shift
@ -135,6 +142,7 @@ gh() {
esac esac
shift shift
done done
endpoint="$(forge_stub_path "$endpoint")"
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
[ -f "$file" ] || return 1 [ -f "$file" ] || return 1
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi