diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6882..62827b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,18 @@ jobs: - uses: actions/checkout@v4 with: 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 run: bash .github/scripts/shellcheck-all.sh - name: Install actionlint diff --git a/actions/issueflow-reconcile/issueflow-reconcile.sh b/actions/issueflow-reconcile/issueflow-reconcile.sh index e0b3174..54c2c23 100644 --- a/actions/issueflow-reconcile/issueflow-reconcile.sh +++ b/actions/issueflow-reconcile/issueflow-reconcile.sh @@ -27,6 +27,10 @@ TRIAGE_ACTORS=() # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=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' "$*"; } 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 local n="$1" marker="$2" message="$3" if issue_comment_has_marker "$n" "$marker"; then return; fi - run gh issue comment "$n" -R "$REPO" --body " + run forge_issue_comment "$n" " $message" >/dev/null } 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 "" } @@ -284,7 +288,7 @@ reference_states() { local ref state while IFS= read -r ref; do [ -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 done } @@ -295,23 +299,23 @@ offsite_pr_states() { [ -n "$ref" ] || continue repo="${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 done } 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() { local n="$1" created="$2" latest latest="$({ 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 # 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' } \ | sort | tail -n1)" @@ -325,7 +329,7 @@ reconcile_issue() { decision="$(queue_decision <<<"$ISSUE_LABELS")" case "$decision" in 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)" ;; FLAG_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). has_issue_label attention && remove_claimed=claimed,attention 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 else - run gh issue edit "$n" -R "$REPO" \ + run forge_issue_edit "$n" \ --remove-label "$remove_claimed" --add-label post-merge >/dev/null fi 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.' owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")" 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 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 log "#$n: stale claim reclaimed -> ready" ;; esac @@ -429,7 +433,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in READY) ensure_comment "$n" blockers-cleared \ '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" ;; esac 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 # quiet (#50 D10), and nothing on the issue side ever puts stale back. 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)" fi [ -n "${age:-}" ] \ @@ -462,11 +466,19 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in reconcile_opened_issue() { 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 # 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). - 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")" is_triage_actor "$author" && triage=true labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" @@ -476,56 +488,73 @@ reconcile_opened_issue() { done remove="${remove#,}" 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 - 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 log "#$n: needs-triage (opened by $author)" } 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}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" load_issueflow_config "$LABELS_CONF" if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}" fi - owner="${REPO%%/*}" - name="${REPO#*/}" - OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' - query($owner: String!, $name: String!, $endCursor: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: 100, states: OPEN, after: $endCursor) { - nodes { closingIssuesReferences(first: 100) { nodes { number } } } - pageInfo { hasNextPage endCursor } - } - } - }' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \ - | sort -nu)" - MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query=' - query($owner: String!, $name: String!, $endCursor: String) { - repository(owner: $owner, name: $name) { - pullRequests(first: 100, states: MERGED, after: $endCursor) { - nodes { number body } - pageInfo { hasNextPage endCursor } - } - } - }' --jq '.data.repository.pullRequests.nodes[] - | .number as $pr | .body | split("\n")[] - | [$pr, .] | @tsv' \ - | while IFS=$'\t' read -r pr body; do + # owner/name split out here until #188 — the GraphQL query took them as + # separate variables. REST takes the owner/name path whole, so it is gone. + # Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL + # API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set + # to the empty string (probe task 278) — so these could not be translated + # to a Forgejo endpoint; there is none. They are REST + a parser this repo + # owns, over `number` and `body`, which /api/v3 and /api/v1 both return in + # the same shape (measured on both, 2026-08-02). + # + # Bodies travel base64 because they contain newlines: jq's @tsv escapes a + # newline to a literal backslash-n, which a line-oriented parser reads as + # one line and silently loses every declaration after the first. The old + # GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same + # protection without needing the split to be correct. + OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \ + --jq '.[] | .body // "" | @base64' \ + | while IFS= read -r b64; do + [ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references + done | sort -nu)" + # closes_references, not refs_references: GitHub's closingIssuesReferences + # meant the CLOSING relation specifically, and reading Refs as closing + # would make every referenced issue look closeable — the distinction #151 + # was reopened by hand over. + 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 [ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr" - done < <(refs_references <<<"$body") + done < <(printf '%s' "$b64" | base64 -d | refs_references) done)" local n - for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \ - --jq '.[] | select(has("pull_request") | not) | .number'); do + for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \ + --jq '.[] | select(.pull_request == null) | .number'); do ( - ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")" - jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0 + ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")" + jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0 ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")" reconcile_issue "$n" ) || log "#$n: reconcile failed — continuing with the remaining issues" diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index 9e7d337..04c84ef 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -53,6 +53,8 @@ STALE_AFTER=$((48 * 3600)) # The needs-ruling invariants (#52) — one implementation for both surfaces. # shellcheck source=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' "$*"; } @@ -185,6 +187,35 @@ set_required_bots() { # the PR author is recused by construction requested() { grep -qxF "$1" <<<"$REQUESTED"; } +# outstanding_requests — 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 # 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 @@ -514,7 +545,7 @@ $(configured_label_rows "$LABELS_CONF")" fi while IFS='|' read -r name color desc; do [ -n "$name" ] || continue - run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force + run forge_label_create "$name" "$color" "$desc" done <<<"$rows" # 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. while IFS= read -r name; do [ -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" 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" # as "not release-shaped" rather than warning on a guess. 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:]')" 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)" fi [ -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 # this path stays cold and the AUTHOR requests the human. 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)" 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 args=(--add-label "$desired${add:+,$add}") [ -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)}" else # 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 # claim is removed. Setting it stays with whoever owns the queue. 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)" fi # ---- 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="$( { jq -r '.created_at' <<<"$PR_JSON" - jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" - gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' - gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' + jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON" + # Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr — + # forge_api names failures loudly, and hiding them re-opens this issue's + # silent-green class (#4879 / #101 D5). + forge_pr_activity "$n" || true } | sort | tail -n1 )" 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. if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; 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" fi 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)" fi @@ -705,6 +742,21 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch } 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}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" load_config "$LABELS_CONF" @@ -717,7 +769,7 @@ main() { # 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. - 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" missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS" @@ -728,17 +780,20 @@ main() { status=0 output="$( ( - PR_JSON="$(gh api "repos/$REPO/pulls/$n")" + PR_JSON="$(forge_api "repos/$REPO/pulls/$n")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" set_required_bots "$AUTHOR" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" BASE_SHA="$(jq -r '.base.sha' <<<"$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 - 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")]')" + # 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 # 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 @@ -753,7 +808,7 @@ main() { # D2), never left to interleave raw into the per-PR output block, # where an unlucky line could collide with a matched string. 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")" rm -f "$GH_VIEW_ERR_FILE" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" @@ -783,7 +838,7 @@ main() { elif [ "$status" -ne 0 ]; then log "#$n: reconcile failed — continuing with the remaining PRs" 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" log "reconciled." } diff --git a/actions/labels-scope/labels-scope.sh b/actions/labels-scope/labels-scope.sh index b8a4544..af39d62 100644 --- a/actions/labels-scope/labels-scope.sh +++ b/actions/labels-scope/labels-scope.sh @@ -6,6 +6,9 @@ else set -u 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 # 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 @@ -68,6 +71,14 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line } parse_labeler_config() { # labeler.yml on stdin → "labelglob" 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, # where an unsupported key is a loud error naming the label it sits under. yq -o=json '.' - | jq -r ' @@ -75,38 +86,38 @@ parse_labeler_config() { # labeler.yml on stdin → "labelglob" lines error("labeler config: top level must be a map of label -> rules") else . end | to_entries[] - | .key as $label + | .key as $lbl | (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)[] | (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) | ((keys - ["changed-files"]) as $extra | 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) | .["changed-files"] | (if type == "object" 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 - 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) | ((keys - ["any-glob-to-any-file"]) as $extra | 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) | .["any-glob-to-any-file"] | (if type == "string" 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 - error("labeler config: \($label): globs must be strings") + error("labeler config: \($lbl): globs must be strings") 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) - | [$label, .] | @tsv + | [$lbl, .] | @tsv ' } @@ -125,6 +136,21 @@ derive_labels() { # $1 = "labelglob" lines, $2 = changed files (one per } 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}" 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}" @@ -134,13 +160,13 @@ main() { # 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 # 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 log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive" return 0 fi 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")" if [ -z "$labels" ]; then @@ -148,8 +174,8 @@ main() { return 0 fi local args=() - while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels" - run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent + while IFS= read -r label; do args+=("$label"); done <<<"$labels" + run forge_labels_add "$PR_NUMBER" "${args[@]}" log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)" } diff --git a/changelog.d/188.md b/changelog.d/188.md new file mode 100644 index 0000000..f85084c --- /dev/null +++ b/changelog.d/188.md @@ -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). diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index eaac5c1..672f09f 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -396,6 +396,13 @@ never before it and never through mixed refs. 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 `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=` 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 diff --git a/lib/closes_references.sh b/lib/closes_references.sh new file mode 100644 index 0000000..d77bef8 --- /dev/null +++ b/lib/closes_references.sh @@ -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 +} diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh new file mode 100644 index 0000000..1aae741 --- /dev/null +++ b/lib/forge-forgejo.sh @@ -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 — pure, so the page-size contract is +# testable without a network. Returns the endpoint with this backend's OWN +# paging parameters applied. +# +# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against +# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub: +# +# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED) +# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped) +# +# Each forge silently ignores the other's page-size parameter, answers +# HTTP 200 with valid JSON, and says nothing. Every call site in this repo +# was written GitHub-shaped, so a verbatim port would have swept 30 of +# rig's 137 and printed "reconciled." — acceptance criterion 2 failing +# green, and the same "degraded read that does not report it degraded" +# failure class this whole issue exists to kill. +# +# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the +# boundary once beats fixing nine call sites and trusting the tenth — the +# same argument that chose shape C over B, one level down. +# +# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on +# this instance) whatever you ask for, so asking for more cannot help and +# pagination is mandatory rather than an optimisation. +forgejo_page_url() { + local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}" + # Strip any page-size parameter a caller left behind, in either dialect, + # rather than trusting that none did: this function is the one place that + # decides paging, and a stray per_page= would be exactly the silent + # truncation above. + local clean="$endpoint" + clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')" + case "$clean" in + *\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;; + *\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;; + *) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;; + esac +} + +# forge_api [--paginate] [--jq ] +# +# --paginate walks page= until a short page, then PROVES the walk was +# complete by comparing what it collected against the server's declared +# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING +# header is a loud refusal, not a pass. Header exposure is a server setting +# (access-control-expose-headers), and an instance that withholds it would +# make the completeness check compare null to a number — the guard itself +# degrading silently, which is the failure class re-entering through the +# door built to stop it. +forge_api() { + local paginate=false endpoint="" jqexpr="" have_jq=false + while [ $# -gt 0 ]; do + case "$1" in + --paginate) paginate=true ;; + --jq) jqexpr="$2"; have_jq=true; shift ;; + -*) ;; + *) [ -n "$endpoint" ] || endpoint="$1" ;; + esac + shift + done + [ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; } + + local base token + base="$(forgejo_api_base)" || return 1 + token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" + + local hdr body + hdr="$(mktemp)"; body="$(mktemp)" + # shellcheck disable=SC2064 # the paths are fixed at trap time on purpose + trap "rm -f '$hdr' '$body'" RETURN + + if [ "$paginate" = false ]; then + if ! curl -sS -D "$hdr" -o "$body" \ + -H "Authorization: token $token" -H 'Accept: application/json' \ + "$base/$endpoint"; then + echo "forge_api: request failed: $endpoint" >&2 + return 1 + fi + forgejo_http_ok "$hdr" "$endpoint" || return 1 + if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi + return 0 + fi + + # Paginated: accumulate into ONE array and apply --jq once at the end. + # gh --paginate applies --jq per page and concatenates; for the `.[] | …` + # shapes every call site here uses, the two are identical, and merging + # first is what makes the completeness assert possible at all. + local page=1 total="" got=0 n all="[]" pagejson + while :; do + if ! curl -sS -D "$hdr" -o "$body" \ + -H "Authorization: token $token" -H 'Accept: application/json' \ + "$base/$(forgejo_page_url "$endpoint" "$page")"; then + echo "forge_api: request failed: $endpoint (page $page)" >&2 + return 1 + fi + forgejo_http_ok "$hdr" "$endpoint" || return 1 + + # Re-read on EVERY page, not once (#4712). A board that changes size + # under the walk was invisible: page 1 declaring 4 and page 2 declaring + # 9 stopped at 4 believing itself whole. A moving total means the read + # cannot have been atomic, so it is refused rather than reconciled. + local page_total + page_total="$(forgejo_total_count "$hdr")" || return 1 + if [ -z "$total" ]; then + total="$page_total" + elif [ "$page_total" != "$total" ]; then + cat >&2 </dev/null)" != array ]; then + cat >&2 <&2 < — the declared size of the collection. +# Absent is fatal (#4699): without it the completeness assert cannot run, +# and an assert that cannot run must not silently pass. +forgejo_total_count() { + local hdr="$1" total + total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)" + if [ -z "$total" ]; then + cat >&2 <&2 < — a non-2xx is named, not +# swallowed. gh exits non-zero on HTTP failure; curl does not without -f, +# and -f would throw away the body that says why. +forgejo_http_ok() { + local hdr="$1" endpoint="$2" code + code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')" + case "$code" in + 2*) return 0 ;; + *) + echo "forge_api: HTTP $code from '$endpoint'" >&2 + return 1 + ;; + esac +} + +# --- the verbs the reconcilers use, over /api/v1 -------------------------- +# Three asymmetries with gh, all measured against this instance on +# 2026-08-02 using a scratch repo (never a live board): +# +# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200 +# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422 +# DELETE /issues/{n}/labels/149 -> 204 +# So a removal must resolve name -> id first. gh hides this; the shim +# cannot. +# +# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the +# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is +# a read-modify-write rather than a delete. +# +# 3. There is no statusCheckRollup. The portable equivalent is the +# combined commit status, GET /commits/{sha}/status, which returns +# {state, statuses[]}. + +# forgejo_label_ids — nameid for every label in the repo, read once per +# call site that needs it. Paginated through forge_api, so a repo with more +# than one page of labels cannot silently lose the tail (#188). +forgejo_label_ids() { + forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"' +} + +# forge_issue_edit [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]… +# gh's flag surface, translated. Accepts comma-separated values, as gh does. +forge_issue_edit() { + local n="${1:?forge_issue_edit: number required}" + shift + local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v + # Unknown flags REFUSE (#4743). The github backend forwards whatever it is + # given to `gh`, which fails on a flag it does not know; dropping it here + # instead would turn a port typo into a green no-op — a mutation that + # silently did not happen, which is precisely this issue's failure class + # arriving inside the fix for it. + while [ $# -gt 0 ]; do + case "$1" in + --add-label | --remove-label | --add-assignee | --remove-assignee) + if [ "$#" -lt 2 ]; then + echo "forge_issue_edit: $1 requires a value (#188)" >&2 + return 1 + fi + IFS=, read -ra v <<<"$2" + case "$1" in + --add-label) add_labels+=("${v[@]}") ;; + --remove-label) rm_labels+=("${v[@]}") ;; + --add-assignee) add_assignees+=("${v[@]}") ;; + --remove-assignee) rm_assignees+=("${v[@]}") ;; + esac + shift + ;; + *) + echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2 + return 1 + ;; + esac + shift + done + + if [ "${#add_labels[@]}" -gt 0 ]; then + local payload + payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')" + forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1 + fi + + if [ "${#rm_labels[@]}" -gt 0 ]; then + local ids id name + ids="$(forgejo_label_ids)" || return 1 + for name in "${rm_labels[@]}"; do + id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" + # A label the repo does not have is not an error: the reconcilers call + # --remove-label unconditionally to converge state, and gh's own + # behaviour there is a no-op. + [ -n "$id" ] || continue + forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1 + done + fi + + if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then + local current want payload + current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1 + want="$( + { + printf '%s\n' "$current" + [ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}" + } | grep -v '^$' | sort -u + )" + if [ "${#rm_assignees[@]}" -gt 0 ]; then + want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)" + fi + payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')" + forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1 + fi +} + +forge_issue_comment() { + local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}" + forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null +} + +forge_pr_list() { + forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number' +} + +# forge_pr_view — the {mergeable, statusCheckRollup} shape the state +# machine reads, assembled from the two places Forgejo keeps it. The rollup +# is mapped into the node shape checks_state already parses, so the decision +# code is untouched. +forge_pr_view() { + local n="${1:?forge_pr_view: number required}" pr sha status + pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1 + sha="$(jq -r '.head.sha // ""' <<<"$pr")" + [ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; } + status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1 + jq -n --argjson pr "$pr" --argjson st "$status" ' + { + mergeable: (if $pr.mergeable == true then "MERGEABLE" + elif $pr.mergeable == false then "CONFLICTING" + else "UNKNOWN" end), + statusCheckRollup: [ + $st.statuses[]? | { + __typename: "StatusContext", + context: .context, + state: (.status | ascii_upcase), + # checks_state groups repeated contexts and takes the NEWEST by + # [.startedAt, .createdAt, .completedAt]. Without a timestamp the + # winner would be decided by incidental array order, so a stale + # re-run could outrank the live verdict (#4743). The combined + # status carries both fields; measured on this instance. + createdAt: .created_at, + completedAt: .updated_at + } + ] + }' +} + +forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; } + +# forge_label_create — an UPSERT, matching `gh label create --force` (#4743). +# bootstrap_labels creates every declared label on every workflow_dispatch, so +# the second dispatch must update rather than conflict; a plain POST onto an +# existing name aborts the bootstrap under set -e. +forge_label_create() { + local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload + payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')" + ids="$(forgejo_label_ids)" || return 1 + id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" + if [ -n "$id" ]; then + forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null + else + forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null + fi +} + +forge_label_delete() { + local name="${1:?}" ids id + ids="$(forgejo_label_ids)" || return 1 + id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")" + [ -n "$id" ] || return 0 + forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null +} + +# forgejo_write — every mutation goes through +# here so a non-2xx is named rather than swallowed, the same contract +# forgejo_http_ok gives reads. +forgejo_write() { + local method="$1" endpoint="$2" payload="$3" base token hdr body rc + base="$(forgejo_api_base)" || return 1 + token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" + hdr="$(mktemp)"; body="$(mktemp)" + if [ -n "$payload" ]; then + curl -sS -X "$method" -D "$hdr" -o "$body" \ + -H "Authorization: token $token" -H 'Content-Type: application/json' \ + -d "$payload" "$base/$endpoint" + else + curl -sS -X "$method" -D "$hdr" -o "$body" \ + -H "Authorization: token $token" "$base/$endpoint" + fi + rc=$? + if [ "$rc" -ne 0 ]; then + rm -f "$hdr" "$body" + echo "forge: $method $endpoint failed to send" >&2 + return 1 + fi + if ! forgejo_http_ok "$hdr" "$method $endpoint"; then + head -c 300 "$body" >&2; echo >&2 + rm -f "$hdr" "$body" + return 1 + fi + cat "$body" + rm -f "$hdr" "$body" +} + +# forge_labels_add — the additive label write (ceremony#128; see +# the github twin). POST /issues/{n}/labels adds the named labels and removes +# nothing, and it takes NAMES — measured, unlike the removal path, which +# needs ids. +forge_labels_add() { + local n="${1:?forge_labels_add: number required}" + shift + [ "$#" -gt 0 ] || return 0 + forgejo_write POST "repos/$REPO/issues/$n/labels" \ + "$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null +} + +# forge_request_reviewer — ask for a verdict. +# +# This endpoint DOES exist here, contrary to an earlier reading of mine +# (#4698) which recorded requested_reviewers as having no sub-resource at +# all. What is true is narrower: Forgejo serves POST and DELETE on it and no +# GET, so a GET probe answers 404 — and a POST naming a user who does not +# exist answers 404 as well, for a different reason. Measured on a scratch +# repo: POST with a real user who lacks read access is 422 ("Reviewer can't +# read"), and 201 once they have it. +# +# The READ stays retired regardless (term 4): the field is stale here even on +# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the +# current head SHA. It is the write that has an answer. +forge_request_reviewer() { + local n="${1:?}" user="${2:?}" + forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \ + "$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null +} + +# forge_timeline — JSON array of timeline events projected into the +# GitHub shape the reconcilers already select on. Measured mapping (#4849): +# +# | | GitHub | Forgejo | +# | event kind | .event == "labeled"/"unlabeled"| .type == "label" | +# | add vs remove | the two event names | .body "1" / "" | +# | actor | .actor.login (no .user) | .user.login (no .actor) | +# +# Status is captured BEFORE jq so an unreadable read cannot report as an +# empty timeline — the two states the ruling ladder must tell apart (#4853). +forge_timeline() { + local n="${1:?forge_timeline: number required}" raw + raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1 + jq ' + [.[] + | select(.type == "label") + | { + event: (if .body == "1" then "labeled" else "unlabeled" end), + actor: {login: (.user.login // "")}, + label: {name: (.label.name // "")}, + created_at: .created_at + } + ] + ' <<<"$raw" +} + +# forge_pr_activity — one ISO timestamp per line of real PR activity. +# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline +# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews +# with comments_count > 0 are fetched, so a board with none costs zero +# extra requests. +forge_pr_activity() { + local n="${1:?forge_pr_activity: number required}" reviews rid + forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1 + forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1 + reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1 + while IFS= read -r rid; do + [ -n "$rid" ] || continue + forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \ + --jq '.[].created_at' || return 1 + done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews") +} diff --git a/lib/forge-github.sh b/lib/forge-github.sh new file mode 100644 index 0000000..c3e9351 --- /dev/null +++ b/lib/forge-github.sh @@ -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] [--jq ] +# +# 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 — 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 — 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 +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 — {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 — 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 — ask 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 — 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 — 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 +} diff --git a/lib/forge.sh b/lib/forge.sh new file mode 100644 index 0000000..bcf1c26 --- /dev/null +++ b/lib/forge.sh @@ -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:///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 <&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 — 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 </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 </dev/null)"; then + # forge_timeline projects both forges into the GitHub event shape + # (.event / .actor.login). Capture its status BEFORE jq: a pipeline's + # status is the last command's, so `forge_timeline | jq` would collapse + # 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" return 0 fi + flags="$(jq -r ' + .[] | select(.event == "labeled" and .label.name == "needs-ruling") + | [.actor.login, .created_at] | @tsv + ' <<<"$timeline")" if [ -z "$flags" ]; then # The label is on the item but no labeled event is visible (a timeline # 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 # body is decoded at its points of use (#73). Do not switch rows to JSON. 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, ((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then 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) ---- if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; 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 escalation comment. Setting it requires the escalation contract — the **question**, the **options**, and a **recommendation** — posted by the @@ -299,7 +306,7 @@ still owed." >/dev/null if [ "$shape" != SHAPED ] \ && [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then 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 is missing required field labels: **$missing**. The contract's shape is fixed because this machinery checks for it (heavy-duty/ceremony#50 D12): @@ -327,7 +334,7 @@ enforced." >/dev/null esac if [ "$rung" = RUNG12 ] \ && [ "$(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 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 @@ -342,7 +349,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null fi if [ "$rung" = RUNG24 ] \ && [ "$(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 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 @@ -375,7 +382,7 @@ timer." >/dev/null else esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter." 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. diff --git a/test/closes_references.test.sh b/test/closes_references.test.sh new file mode 100644 index 0000000..330ed1e --- /dev/null +++ b/test/closes_references.test.sh @@ -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 — the parse of is exactly +# . 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 diff --git a/test/facts.test.sh b/test/facts.test.sh index 32bea4a..ad2a9e3 100644 --- a/test/facts.test.sh +++ b/test/facts.test.sh @@ -10,6 +10,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=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" diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh new file mode 100644 index 0000000..cc02d1f --- /dev/null +++ b/test/forge-backends.test.sh @@ -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 — install a curl stub serving as +# successive page bodies, declaring 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 diff --git a/test/forge.test.sh b/test/forge.test.sh new file mode 100644 index 0000000..85b2b9e --- /dev/null +++ b/test/forge.test.sh @@ -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 — succeeds AND prints exactly . 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 — 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 \ No newline at end of file diff --git a/test/harness.sh b/test/harness.sh index 00e05f6..3b8b3bd 100644 --- a/test/harness.sh +++ b/test/harness.sh @@ -34,3 +34,13 @@ summary() { [ "$FAIL" -eq 0 ] } + +# forge_stub_path — 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' +} diff --git a/test/issueflow-reconcile.test.sh b/test/issueflow-reconcile.test.sh index a326da9..858d1cd 100644 --- a/test/issueflow-reconcile.test.sh +++ b/test/issueflow-reconcile.test.sh @@ -4,6 +4,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=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 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 check "cross-repo warning is idempotent across two sweeps" 0 "" \ 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" test "$(grep -cF "" "$GH_COMMENTS")" -eq 1' \ @@ -234,6 +240,7 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \ INOW=2000000000 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() { if [ "$1" = api ]; then shift @@ -246,6 +253,7 @@ issue_stub_gh() { esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" printf '%s\n' "$endpoint" >>"$TMP/api-calls" [ ! -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="" fi run() { "$@"; } + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { issue_stub_gh "$@"; } 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)))" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \ >"$(cfix 24)" +# shellcheck disable=SC2317 # reached through the forge backend (#188) churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; } last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )" check "last activity ignores the 2-day-old label churn" 0 "" \ @@ -566,6 +576,11 @@ if [ "$1" = api ]; then esac shift 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" [ ! -f "$file.error" ] || exit 1 if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi @@ -577,13 +592,22 @@ echo "gh stub: unexpected call: gh $*" >&2 exit 97 EOF chmod +x "$ARRIVAL/stub/gh" -printf '%s\n' \ - '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ - >"$ARRIVAL/fixtures/graphql.json" +# The two PR gathers were GraphQL until #188; they are REST now, so the +# fixtures are the /pulls list both forges return. Empty by default — the +# 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"; } +# 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/fixtures/edits" env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \ 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" # 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 -# a sourced decision probe cannot exercise (#91's lesson). +# Keep this at main() granularity: the PR gather and loop are the code a +# 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' \ - '{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ - >"$ARRIVAL/fixtures/graphql.json" + '[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \ + >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json" 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")" \ '{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" @@ -633,18 +661,95 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json" : >"$ARRIVAL/fixtures/edits" subprocess_out="$( env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \ + CEREMONY_FORGE=github \ REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \ bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1 )" subprocess_rc=$? check "executable sweep transitions merged Refs work" 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" 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' \ "$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 # the arrival path still kills the run loudly. : >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error" diff --git a/test/labels-reconcile.test.sh b/test/labels-reconcile.test.sh index 00fcafd..87f4107 100755 --- a/test/labels-reconcile.test.sh +++ b/test/labels-reconcile.test.sh @@ -13,6 +13,19 @@ export LC_ALL=C cd "$(dirname "$0")/.." # shellcheck source=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 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 PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' run() { :; } # swallow mutations + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { :; } # no network 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 PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}' run() { :; } # swallow mutations + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { :; } # no network 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 PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')" run() { "$@"; } # mutations reach the stub and are recorded, not swallowed + # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { if [ "$1" = api ]; then shift @@ -671,6 +687,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json" # A missing fixture is an empty collection — projected through the # caller's --jq exactly like real gh, so '.[].foo' yields no lines. @@ -738,6 +755,10 @@ blind_main_probe() { GITHUB_EVENT_NAME=schedule REPO=owner/repo 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() { if [ "$1" = label ] && [ "$2" = list ]; then 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" \ "$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" \ "$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 # guard keeps that from aborting the dispatch. Red without the guard. @@ -895,7 +916,7 @@ boot_dry_probe() { } dry_out="$(boot_dry_probe)" 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" \ 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/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" \ bash actions/labels-reconcile/labels-reconcile.sh } @@ -946,5 +968,51 @@ for ev in schedule pull_request_target; do expect "...and deletes nothing" \ no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)" 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" [ "$fail" -eq 0 ] diff --git a/test/release-chain.test.sh b/test/release-chain.test.sh index 8733425..3633824 100644 --- a/test/release-chain.test.sh +++ b/test/release-chain.test.sh @@ -11,6 +11,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=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" DECIDE="$ROOT/lib/decide.sh" diff --git a/test/ruling.test.sh b/test/ruling.test.sh index 2eaffb7..7150e39 100644 --- a/test/ruling.test.sh +++ b/test/ruling.test.sh @@ -4,6 +4,12 @@ set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=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 source "$ROOT/lib/ruling.sh" @@ -123,6 +129,7 @@ run() { "$@"; } iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; } +# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188) gh() { if [ "$1" = api ]; then shift @@ -135,6 +142,7 @@ gh() { esac shift done + endpoint="$(forge_stub_path "$endpoint")" file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json" [ -f "$file" ] || return 1 if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi