Merge pull request 'actions/* + lib/* — one forge abstraction, two backends (#188)' (#189) from build/188-forge-preflight into main
All checks were successful
CI / test (push) Successful in 1m25s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 5s
All checks were successful
CI / test (push) Successful in 1m25s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 5s
Reviewed-on: #189 Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders> Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders> Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
This commit is contained in:
commit
7fc9afe45f
20 changed files with 2319 additions and 105 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -15,6 +15,18 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
|
||||||
|
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
|
||||||
|
# actionlint already self-installs below — the same for shellcheck, so a
|
||||||
|
# green head is reachable once a ceremony runner is online (#188).
|
||||||
|
- name: Install shellcheck
|
||||||
|
env:
|
||||||
|
SHELLCHECK_VERSION: 0.10.0
|
||||||
|
run: |
|
||||||
|
curl -fsSLo shellcheck.tar.xz \
|
||||||
|
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
|
||||||
|
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
|
||||||
|
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
|
||||||
- name: Shellcheck
|
- name: Shellcheck
|
||||||
run: bash .github/scripts/shellcheck-all.sh
|
run: bash .github/scripts/shellcheck-all.sh
|
||||||
- name: Install actionlint
|
- name: Install actionlint
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ TRIAGE_ACTORS=()
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||||
|
# shellcheck source=lib/closes_references.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
|
||||||
|
|
||||||
log() { printf 'issueflow: %s\n' "$*"; }
|
log() { printf 'issueflow: %s\n' "$*"; }
|
||||||
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
|
||||||
|
|
@ -271,12 +275,12 @@ offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
|
||||||
ensure_comment() { # $1 issue, $2 marker, $3 message
|
ensure_comment() { # $1 issue, $2 marker, $3 message
|
||||||
local n="$1" marker="$2" message="$3"
|
local n="$1" marker="$2" message="$3"
|
||||||
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
if issue_comment_has_marker "$n" "$marker"; then return; fi
|
||||||
run gh issue comment "$n" -R "$REPO" --body "<!-- issueflow:$marker -->
|
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
|
||||||
$message" >/dev/null
|
$message" >/dev/null
|
||||||
}
|
}
|
||||||
|
|
||||||
issue_comment_has_marker() { # $1 issue, $2 marker
|
issue_comment_has_marker() { # $1 issue, $2 marker
|
||||||
gh api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
|
||||||
| grep -qF "<!-- issueflow:$2 -->"
|
| grep -qF "<!-- issueflow:$2 -->"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +288,7 @@ reference_states() {
|
||||||
local ref state
|
local ref state
|
||||||
while IFS= read -r ref; do
|
while IFS= read -r ref; do
|
||||||
[ -n "$ref" ] || continue
|
[ -n "$ref" ] || continue
|
||||||
state="$(gh api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
||||||
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
@ -295,23 +299,23 @@ offsite_pr_states() {
|
||||||
[ -n "$ref" ] || continue
|
[ -n "$ref" ] || continue
|
||||||
repo="${ref%#*}"
|
repo="${ref%#*}"
|
||||||
number="${ref##*#}"
|
number="${ref##*#}"
|
||||||
state="$(gh api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
|
||||||
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
offsite_timeline() { # unreadable timelines are deliberately silent
|
offsite_timeline() { # unreadable timelines are deliberately silent
|
||||||
gh api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
last_issue_activity() {
|
last_issue_activity() {
|
||||||
local n="$1" created="$2" latest
|
local n="$1" created="$2" latest
|
||||||
latest="$({
|
latest="$({
|
||||||
printf '%s\n' "$created"
|
printf '%s\n' "$created"
|
||||||
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
||||||
# Assignment is the claim itself. Ignoring it would let an old issue be
|
# Assignment is the claim itself. Ignoring it would let an old issue be
|
||||||
# reclaimed in the seconds between assignment and its required draft PR.
|
# reclaimed in the seconds between assignment and its required draft PR.
|
||||||
gh api --paginate "repos/$REPO/issues/$n/timeline" \
|
forge_api --paginate "repos/$REPO/issues/$n/timeline" \
|
||||||
--jq '.[] | select(.event == "assigned") | .created_at'
|
--jq '.[] | select(.event == "assigned") | .created_at'
|
||||||
} \
|
} \
|
||||||
| sort | tail -n1)"
|
| sort | tail -n1)"
|
||||||
|
|
@ -325,7 +329,7 @@ reconcile_issue() {
|
||||||
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
decision="$(queue_decision <<<"$ISSUE_LABELS")"
|
||||||
case "$decision" in
|
case "$decision" in
|
||||||
ADD_NEEDS_TRIAGE)
|
ADD_NEEDS_TRIAGE)
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
|
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
||||||
log "#$n: needs-triage (no queue state)" ;;
|
log "#$n: needs-triage (no queue state)" ;;
|
||||||
FLAG_CONFLICT)
|
FLAG_CONFLICT)
|
||||||
ensure_comment "$n" queue-conflict \
|
ensure_comment "$n" queue-conflict \
|
||||||
|
|
@ -358,10 +362,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
# would create an impossible parked-for state (#175 D4).
|
# would create an impossible parked-for state (#175 D4).
|
||||||
has_issue_label attention && remove_claimed=claimed,attention
|
has_issue_label attention && remove_claimed=claimed,attention
|
||||||
if [ -n "$owners" ]; then
|
if [ -n "$owners" ]; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
||||||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
else
|
else
|
||||||
run gh issue edit "$n" -R "$REPO" \
|
run forge_issue_edit "$n" \
|
||||||
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: merged Refs PR -> post-merge; claim released"
|
log "#$n: merged Refs PR -> post-merge; claim released"
|
||||||
|
|
@ -387,10 +391,10 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
|
||||||
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
|
||||||
if [ -n "$owners" ]; then
|
if [ -n "$owners" ]; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-assignee "$owners" \
|
run forge_issue_edit "$n" --remove-assignee "$owners" \
|
||||||
--remove-label claimed --add-label ready >/dev/null
|
--remove-label claimed --add-label ready >/dev/null
|
||||||
else
|
else
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label claimed --add-label ready >/dev/null
|
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: stale claim reclaimed -> ready" ;;
|
log "#$n: stale claim reclaimed -> ready" ;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -429,7 +433,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
READY)
|
READY)
|
||||||
ensure_comment "$n" blockers-cleared \
|
ensure_comment "$n" blockers-cleared \
|
||||||
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
|
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label blocked --add-label ready >/dev/null
|
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
|
||||||
log "#$n: blockers closed -> ready" ;;
|
log "#$n: blockers closed -> ready" ;;
|
||||||
esac
|
esac
|
||||||
elif has_issue_label epic; then
|
elif has_issue_label epic; then
|
||||||
|
|
@ -451,7 +455,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
# An already-applied stale comes off: waiting on a human is legitimately
|
# An already-applied stale comes off: waiting on a human is legitimately
|
||||||
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
|
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
|
||||||
if has_issue_label stale; then
|
if has_issue_label stale; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
||||||
log "#$n: unstale (a ruling is pending)"
|
log "#$n: unstale (a ruling is pending)"
|
||||||
fi
|
fi
|
||||||
[ -n "${age:-}" ] \
|
[ -n "${age:-}" ] \
|
||||||
|
|
@ -462,11 +466,19 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
|
||||||
|
|
||||||
reconcile_opened_issue() {
|
reconcile_opened_issue() {
|
||||||
local n="$1" author triage=false labels remove="" label
|
local n="$1" author triage=false labels remove="" label
|
||||||
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
|
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
||||||
# The stand-downs return 0 explicitly: a bare return carries the failed
|
# The stand-downs return 0 explicitly: a bare return carries the failed
|
||||||
# test's status, which under execution is live `set -e` — and it killed the
|
# test's status, which under execution is live `set -e` — and it killed the
|
||||||
# run on every triage-authored mint, before one issue was reconciled (#91).
|
# run on every triage-authored mint, before one issue was reconciled (#91).
|
||||||
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0
|
# `.pull_request == null`, NOT `has("pull_request") | not` (#188). The key's
|
||||||
|
# PRESENCE is a GitHub-only discriminator: GitHub omits it on a plain issue,
|
||||||
|
# Forgejo emits it on every entry and sets it to null. Measured on the list
|
||||||
|
# endpoints, 2026-08-02 — GitHub 0 of 9 issues carried the key, Forgejo 10 of
|
||||||
|
# 10 did. So the old test selected ZERO issues here and the sweep printed
|
||||||
|
# "reconciled." over an untouched board: the blind sweep again, one layer in.
|
||||||
|
# The null test is true for an issue on both forges (absent key reads as
|
||||||
|
# null) and false for a PR on both.
|
||||||
|
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || return 0
|
||||||
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
|
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
|
||||||
is_triage_actor "$author" && triage=true
|
is_triage_actor "$author" && triage=true
|
||||||
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
||||||
|
|
@ -476,56 +488,73 @@ reconcile_opened_issue() {
|
||||||
done
|
done
|
||||||
remove="${remove#,}"
|
remove="${remove#,}"
|
||||||
if [ -n "$remove" ]; then
|
if [ -n "$remove" ]; then
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label needs-triage --remove-label "$remove" >/dev/null
|
run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
|
||||||
else
|
else
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label needs-triage >/dev/null
|
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
|
||||||
fi
|
fi
|
||||||
log "#$n: needs-triage (opened by $author)"
|
log "#$n: needs-triage (opened by $author)"
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local owner name
|
# See labels-reconcile's twin (#188). This one already failed loudly on
|
||||||
|
# Forgejo — but with `line 408: gh: command not found`, which names the
|
||||||
|
# symptom and not the cause, and only after the sibling step had already
|
||||||
|
# reported a green blind sweep.
|
||||||
|
# The forge is decided once, here, before anything reads the board, and
|
||||||
|
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
||||||
|
# wrapper that stood here died with the call-site port: it declared "this
|
||||||
|
# code uses gh", which stopped being true the moment every site went
|
||||||
|
# through the shim, and leaving it would have defaulted the forgejo path
|
||||||
|
# into the very client its own preflight refuses.
|
||||||
|
forge_preflight || return 1
|
||||||
|
# "" means decide from the environment; forge_select takes an explicit
|
||||||
|
# forge only in tests.
|
||||||
|
forge_select "" || return 1
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
||||||
load_issueflow_config "$LABELS_CONF"
|
load_issueflow_config "$LABELS_CONF"
|
||||||
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
|
||||||
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
|
||||||
fi
|
fi
|
||||||
owner="${REPO%%/*}"
|
# owner/name split out here until #188 — the GraphQL query took them as
|
||||||
name="${REPO#*/}"
|
# separate variables. REST takes the owner/name path whole, so it is gone.
|
||||||
OPEN_PR_ISSUES="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
# Both gathers were `gh api graphql` until #188. Forgejo has NO GraphQL
|
||||||
query($owner: String!, $name: String!, $endCursor: String) {
|
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
|
||||||
repository(owner: $owner, name: $name) {
|
# to the empty string (probe task 278) — so these could not be translated
|
||||||
pullRequests(first: 100, states: OPEN, after: $endCursor) {
|
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
|
||||||
nodes { closingIssuesReferences(first: 100) { nodes { number } } }
|
# owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
|
||||||
pageInfo { hasNextPage endCursor }
|
# the same shape (measured on both, 2026-08-02).
|
||||||
}
|
#
|
||||||
}
|
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
|
||||||
}' --jq '.data.repository.pullRequests.nodes[].closingIssuesReferences.nodes[].number' \
|
# newline to a literal backslash-n, which a line-oriented parser reads as
|
||||||
| sort -nu)"
|
# one line and silently loses every declaration after the first. The old
|
||||||
MERGED_REF_PR_RECORDS="$(gh api graphql --paginate -f owner="$owner" -f name="$name" -f query='
|
# GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
|
||||||
query($owner: String!, $name: String!, $endCursor: String) {
|
# protection without needing the split to be correct.
|
||||||
repository(owner: $owner, name: $name) {
|
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open" \
|
||||||
pullRequests(first: 100, states: MERGED, after: $endCursor) {
|
--jq '.[] | .body // "" | @base64' \
|
||||||
nodes { number body }
|
| while IFS= read -r b64; do
|
||||||
pageInfo { hasNextPage endCursor }
|
[ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
|
||||||
}
|
done | sort -nu)"
|
||||||
}
|
# closes_references, not refs_references: GitHub's closingIssuesReferences
|
||||||
}' --jq '.data.repository.pullRequests.nodes[]
|
# meant the CLOSING relation specifically, and reading Refs as closing
|
||||||
| .number as $pr | .body | split("\n")[]
|
# would make every referenced issue look closeable — the distinction #151
|
||||||
| [$pr, .] | @tsv' \
|
# was reopened by hand over.
|
||||||
| while IFS=$'\t' read -r pr body; do
|
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed" \
|
||||||
|
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
|
||||||
|
| while IFS=$'\t' read -r pr b64; do
|
||||||
|
[ -n "$b64" ] || continue
|
||||||
while IFS= read -r issue; do
|
while IFS= read -r issue; do
|
||||||
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
|
||||||
done < <(refs_references <<<"$body")
|
done < <(printf '%s' "$b64" | base64 -d | refs_references)
|
||||||
done)"
|
done)"
|
||||||
|
|
||||||
local n
|
local n
|
||||||
for n in $(gh api --paginate "repos/$REPO/issues?state=open&per_page=100" \
|
for n in $(forge_api --paginate "repos/$REPO/issues?state=open" \
|
||||||
--jq '.[] | select(has("pull_request") | not) | .number'); do
|
--jq '.[] | select(.pull_request == null) | .number'); do
|
||||||
(
|
(
|
||||||
ISSUE_JSON="$(gh api "repos/$REPO/issues/$n")"
|
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
|
||||||
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
jq -e '.pull_request == null' <<<"$ISSUE_JSON" >/dev/null || exit 0
|
||||||
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
|
||||||
reconcile_issue "$n"
|
reconcile_issue "$n"
|
||||||
) || log "#$n: reconcile failed — continuing with the remaining issues"
|
) || log "#$n: reconcile failed — continuing with the remaining issues"
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ STALE_AFTER=$((48 * 3600))
|
||||||
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
# The needs-ruling invariants (#52) — one implementation for both surfaces.
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||||
|
|
||||||
log() { printf 'labels: %s\n' "$*"; }
|
log() { printf 'labels: %s\n' "$*"; }
|
||||||
|
|
||||||
|
|
@ -185,6 +187,35 @@ set_required_bots() { # the PR author is recused by construction
|
||||||
|
|
||||||
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
requested() { grep -qxF "$1" <<<"$REQUESTED"; }
|
||||||
|
|
||||||
|
# outstanding_requests <requested-logins> — the portable "who still owes a
|
||||||
|
# verdict on THIS head" (issue #188, term 4).
|
||||||
|
#
|
||||||
|
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
|
||||||
|
# field already answers this question and the filter below removes nothing.
|
||||||
|
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
|
||||||
|
# panelists with all three verdicts in, and rig!146 still lists three while
|
||||||
|
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
|
||||||
|
#
|
||||||
|
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
|
||||||
|
# stops blocker:unrequested from ever being true: the sweep believes a round
|
||||||
|
# is permanently live. So the requested set is intersected with "has not
|
||||||
|
# submitted a verdict for the current head", which is derived from
|
||||||
|
# /pulls/{n}/reviews — the read that is true on both forges.
|
||||||
|
#
|
||||||
|
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
|
||||||
|
# whose only verdict is STALE still owes one, which is why this asks
|
||||||
|
# bot_verdict rather than merely "has any review".
|
||||||
|
outstanding_requests() {
|
||||||
|
local login
|
||||||
|
while IFS= read -r login; do
|
||||||
|
[ -n "$login" ] || continue
|
||||||
|
case "$(bot_verdict "$login")" in
|
||||||
|
APPROVE | BLOCK | FEEDBACK) continue ;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$login"
|
||||||
|
done <<<"${1-}"
|
||||||
|
}
|
||||||
|
|
||||||
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
|
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
|
||||||
# UNREADABLE is the absence of the key itself, which is what a failed fetch
|
# UNREADABLE is the absence of the key itself, which is what a failed fetch
|
||||||
# leaves behind — distinct from a present-but-empty rollup, which honestly
|
# leaves behind — distinct from a present-but-empty rollup, which honestly
|
||||||
|
|
@ -514,7 +545,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
fi
|
fi
|
||||||
while IFS='|' read -r name color desc; do
|
while IFS='|' read -r name color desc; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
run forge_label_create "$name" "$color" "$desc"
|
||||||
done <<<"$rows"
|
done <<<"$rows"
|
||||||
|
|
||||||
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
|
||||||
|
|
@ -528,7 +559,7 @@ $(configured_label_rows "$LABELS_CONF")"
|
||||||
# the taxonomy it can create. Either way: log the name, keep going.
|
# the taxonomy it can create. Either way: log the name, keep going.
|
||||||
while IFS= read -r name; do
|
while IFS= read -r name; do
|
||||||
[ -n "$name" ] || continue
|
[ -n "$name" ] || continue
|
||||||
run gh label delete "$name" -R "$REPO" --yes \
|
run forge_label_delete "$name" \
|
||||||
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
|
||||||
done <<<"$(retired_label_names)"
|
done <<<"$(retired_label_names)"
|
||||||
}
|
}
|
||||||
|
|
@ -557,10 +588,10 @@ tree_version() { # $1 = ref → that tree's version via the API, or nothing
|
||||||
# Every failure path prints nothing: the caller treats "could not read"
|
# Every failure path prints nothing: the caller treats "could not read"
|
||||||
# as "not release-shaped" rather than warning on a guess.
|
# as "not release-shaped" rather than warning on a guess.
|
||||||
local ref="$1" ver
|
local ref="$1" ver
|
||||||
ver="$(gh api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
| base64 -d 2>/dev/null | tr -d '[:space:]')"
|
||||||
if [ -z "$ver" ]; then
|
if [ -z "$ver" ]; then
|
||||||
ver="$(gh api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
|
||||||
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
|
||||||
fi
|
fi
|
||||||
[ -z "$ver" ] || printf '%s\n' "$ver"
|
[ -z "$ver" ] || printf '%s\n' "$ver"
|
||||||
|
|
@ -581,7 +612,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# concurrency group in labels.yml. With a comment-only bot on the panel
|
# concurrency group in labels.yml. With a comment-only bot on the panel
|
||||||
# this path stays cold and the AUTHOR requests the human.
|
# this path stays cold and the AUTHOR requests the human.
|
||||||
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
if [ "$desired" = state:needs-human ] && human_request_needed; then
|
||||||
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent
|
run forge_request_reviewer "$n" "$HUMAN"
|
||||||
log "#$n: requested $HUMAN (round passed)"
|
log "#$n: requested $HUMAN (round passed)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -643,7 +674,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
|
||||||
args=(--add-label "$desired${add:+,$add}")
|
args=(--add-label "$desired${add:+,$add}")
|
||||||
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
[ -n "$remove" ] && args+=(--remove-label "$remove")
|
||||||
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then
|
if run forge_issue_edit "$n" "${args[@]}" >/dev/null; then
|
||||||
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
|
||||||
else
|
else
|
||||||
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
# a deleted label must not wedge the sweep — dispatch heals the taxonomy
|
||||||
|
|
@ -666,18 +697,24 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# the moment the PR is no longer the thing a human should merge next, the
|
# the moment the PR is no longer the thing a human should merge next, the
|
||||||
# claim is removed. Setting it stays with whoever owns the queue.
|
# claim is removed. Setting it stays with whoever owns the queue.
|
||||||
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
if has_label merge-next && [ "$desired" != state:needs-human ]; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null
|
run forge_issue_edit "$n" --remove-label merge-next >/dev/null
|
||||||
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
# ---- stale: real activity only, and blocked is legitimately quiet ----
|
||||||
|
# forge_pr_activity owns the portable half: issue comments + commits +
|
||||||
|
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
|
||||||
|
# Forgejo; the forgejo backend re-derives it from reviews with
|
||||||
|
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
|
||||||
|
# stay here — they are already in hand and need no second fetch.
|
||||||
last_activity="$(
|
last_activity="$(
|
||||||
{
|
{
|
||||||
jq -r '.created_at' <<<"$PR_JSON"
|
jq -r '.created_at' <<<"$PR_JSON"
|
||||||
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON"
|
jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
|
||||||
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
|
# Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
|
# forge_api names failures loudly, and hiding them re-opens this issue's
|
||||||
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
|
# silent-green class (#4879 / #101 D5).
|
||||||
|
forge_pr_activity "$n" || true
|
||||||
} | sort | tail -n1
|
} | sort | tail -n1
|
||||||
)"
|
)"
|
||||||
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
last_activity_epoch="$(date -d "$last_activity" +%s)"
|
||||||
|
|
@ -686,11 +723,11 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
|
||||||
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
|
||||||
if has_label stale; then
|
if has_label stale; then
|
||||||
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null
|
run forge_issue_edit "$n" --remove-label stale >/dev/null
|
||||||
log "#$n: unstale"
|
log "#$n: unstale"
|
||||||
fi
|
fi
|
||||||
elif ! has_label stale; then
|
elif ! has_label stale; then
|
||||||
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null
|
run forge_issue_edit "$n" --add-label stale >/dev/null
|
||||||
log "#$n: stale ($((age / 3600))h quiet)"
|
log "#$n: stale ($((age / 3600))h quiet)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -705,6 +742,21 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
# BEFORE anything reads the board (#188). Every call site below is still
|
||||||
|
# `gh`, so that is what this declares — honestly, which is the point: on
|
||||||
|
# a Forgejo consumer the preflight refuses here instead of letting the
|
||||||
|
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
|
||||||
|
# The forge is decided once, here, before anything reads the board, and
|
||||||
|
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
||||||
|
# wrapper that stood here died with the call-site port: it declared "this
|
||||||
|
# code uses gh", which stopped being true the moment every site went
|
||||||
|
# through the shim, and leaving it would have defaulted the forgejo path
|
||||||
|
# into the very client its own preflight refuses.
|
||||||
|
forge_preflight || return 1
|
||||||
|
# "" means decide from the environment; forge_select takes an explicit
|
||||||
|
# forge only in tests.
|
||||||
|
forge_select "" || return 1
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
|
||||||
load_config "$LABELS_CONF"
|
load_config "$LABELS_CONF"
|
||||||
|
|
@ -717,7 +769,7 @@ main() {
|
||||||
|
|
||||||
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
# The repo's label set, read ONCE per sweep — reconcile_pr filters every
|
||||||
# add against it, because one unknown name fails the whole edit call.
|
# add against it, because one unknown name fails the whole edit call.
|
||||||
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")"
|
REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
|
||||||
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
|
||||||
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
|
||||||
|
|
||||||
|
|
@ -728,17 +780,20 @@ main() {
|
||||||
status=0
|
status=0
|
||||||
output="$(
|
output="$(
|
||||||
(
|
(
|
||||||
PR_JSON="$(gh api "repos/$REPO/pulls/$n")"
|
PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
|
||||||
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
|
||||||
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
|
||||||
set_required_bots "$AUTHOR"
|
set_required_bots "$AUTHOR"
|
||||||
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
|
||||||
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
|
||||||
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
|
||||||
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
|
|
||||||
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
|
||||||
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
|
||||||
| jq -s '[.[] | select(.state != "PENDING")]')"
|
| jq -s '[.[] | select(.state != "PENDING")]')"
|
||||||
|
# Read AFTER the reviews, because the raw field is not portable: Forgejo
|
||||||
|
# never clears it, so it is intersected with who still owes a verdict on
|
||||||
|
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
|
||||||
|
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
|
||||||
# mergeability + the check rollup, the two facts the state machine was
|
# mergeability + the check rollup, the two facts the state machine was
|
||||||
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
|
||||||
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
# `mergeable` is a tri-state boolean that GitHub computes lazily, while
|
||||||
|
|
@ -753,7 +808,7 @@ main() {
|
||||||
# D2), never left to interleave raw into the per-PR output block,
|
# D2), never left to interleave raw into the per-PR output block,
|
||||||
# where an unlucky line could collide with a matched string.
|
# where an unlucky line could collide with a matched string.
|
||||||
GH_VIEW_ERR_FILE="$(mktemp)"
|
GH_VIEW_ERR_FILE="$(mktemp)"
|
||||||
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
|
||||||
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
|
||||||
rm -f "$GH_VIEW_ERR_FILE"
|
rm -f "$GH_VIEW_ERR_FILE"
|
||||||
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
|
||||||
|
|
@ -783,7 +838,7 @@ main() {
|
||||||
elif [ "$status" -ne 0 ]; then
|
elif [ "$status" -ne 0 ]; then
|
||||||
log "#$n: reconcile failed — continuing with the remaining PRs"
|
log "#$n: reconcile failed — continuing with the remaining PRs"
|
||||||
fi
|
fi
|
||||||
done < <(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number')
|
done < <(forge_pr_list)
|
||||||
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
|
||||||
log "reconciled."
|
log "reconciled."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ else
|
||||||
set -u
|
set -u
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
|
||||||
|
|
||||||
# labels-scope.sh — the additive half of the labels automation: derive
|
# labels-scope.sh — the additive half of the labels automation: derive
|
||||||
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
# scope:* labels from a PR's changed paths and ADD them, touching nothing
|
||||||
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
# else. This seat belonged to actions/labeler@v5 until #130: even under
|
||||||
|
|
@ -68,6 +71,14 @@ glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
||||||
|
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
|
||||||
|
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
|
||||||
|
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
|
||||||
|
# jq 1.7 parses it, which is why this survived: GitHub's hosted
|
||||||
|
# ubuntu-latest ships 1.7, and the Forgejo runner image
|
||||||
|
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
|
||||||
|
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
|
||||||
|
# jq compile error before the config was even read.
|
||||||
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
|
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
|
||||||
# where an unsupported key is a loud error naming the label it sits under.
|
# where an unsupported key is a loud error naming the label it sits under.
|
||||||
yq -o=json '.' - | jq -r '
|
yq -o=json '.' - | jq -r '
|
||||||
|
|
@ -75,38 +86,38 @@ parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
|
||||||
error("labeler config: top level must be a map of label -> rules")
|
error("labeler config: top level must be a map of label -> rules")
|
||||||
else . end
|
else . end
|
||||||
| to_entries[]
|
| to_entries[]
|
||||||
| .key as $label
|
| .key as $lbl
|
||||||
| (if (.value | type) != "array" then
|
| (if (.value | type) != "array" then
|
||||||
error("labeler config: \($label): rules must be a list")
|
error("labeler config: \($lbl): rules must be a list")
|
||||||
else .value end)[]
|
else .value end)[]
|
||||||
| (if type != "object" then
|
| (if type != "object" then
|
||||||
error("labeler config: \($label): each rule must be a map")
|
error("labeler config: \($lbl): each rule must be a map")
|
||||||
else . end)
|
else . end)
|
||||||
| ((keys - ["changed-files"]) as $extra
|
| ((keys - ["changed-files"]) as $extra
|
||||||
| if ($extra | length) > 0 then
|
| if ($extra | length) > 0 then
|
||||||
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
|
error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| .["changed-files"]
|
| .["changed-files"]
|
||||||
| (if type == "object" then [.]
|
| (if type == "object" then [.]
|
||||||
elif type == "array" then .
|
elif type == "array" then .
|
||||||
else error("labeler config: \($label): changed-files must be a list") end)[]
|
else error("labeler config: \($lbl): changed-files must be a list") end)[]
|
||||||
| (if type != "object" then
|
| (if type != "object" then
|
||||||
error("labeler config: \($label): each changed-files entry must be a map")
|
error("labeler config: \($lbl): each changed-files entry must be a map")
|
||||||
else . end)
|
else . end)
|
||||||
| ((keys - ["any-glob-to-any-file"]) as $extra
|
| ((keys - ["any-glob-to-any-file"]) as $extra
|
||||||
| if ($extra | length) > 0 then
|
| if ($extra | length) > 0 then
|
||||||
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
|
error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| .["any-glob-to-any-file"]
|
| .["any-glob-to-any-file"]
|
||||||
| (if type == "string" then [.]
|
| (if type == "string" then [.]
|
||||||
elif type == "array" then .
|
elif type == "array" then .
|
||||||
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[]
|
else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
|
||||||
| (if type != "string" then
|
| (if type != "string" then
|
||||||
error("labeler config: \($label): globs must be strings")
|
error("labeler config: \($lbl): globs must be strings")
|
||||||
elif contains("\\") then
|
elif contains("\\") then
|
||||||
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)")
|
error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
|
||||||
else . end)
|
else . end)
|
||||||
| [$label, .] | @tsv
|
| [$lbl, .] | @tsv
|
||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +136,21 @@ derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
|
# See labels-reconcile's twin (#188). This action's degraded read was the
|
||||||
|
# quietest of the three: an unreadable mapping and an absent one produced
|
||||||
|
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
|
||||||
|
# scope labels and nothing said why.
|
||||||
|
# The forge is decided once, here, before anything reads the board, and
|
||||||
|
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
|
||||||
|
# wrapper that stood here died with the call-site port: it declared "this
|
||||||
|
# code uses gh", which stopped being true the moment every site went
|
||||||
|
# through the shim, and leaving it would have defaulted the forgejo path
|
||||||
|
# into the very client its own preflight refuses.
|
||||||
|
forge_preflight || return 1
|
||||||
|
# "" means decide from the environment; forge_select takes an explicit
|
||||||
|
# forge only in tests.
|
||||||
|
forge_select "" || return 1
|
||||||
|
|
||||||
REPO="${REPO:?set REPO to owner/name}"
|
REPO="${REPO:?set REPO to owner/name}"
|
||||||
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
|
||||||
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
|
||||||
|
|
@ -134,13 +160,13 @@ main() {
|
||||||
# No mapping is a consumer that has not adopted scope labels — an
|
# No mapping is a consumer that has not adopted scope labels — an
|
||||||
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
# advisory no-op, not a red run (scopes locate, they do not alert). A
|
||||||
# mapping that EXISTS but does not parse still fails loudly below.
|
# mapping that EXISTS but does not parse still fails loudly below.
|
||||||
if ! config="$(gh api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
|
||||||
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
|
||||||
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
tsv="$(parse_labeler_config <<<"$config")"
|
tsv="$(parse_labeler_config <<<"$config")"
|
||||||
files="$(gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
|
||||||
labels="$(derive_labels "$tsv" "$files")"
|
labels="$(derive_labels "$tsv" "$files")"
|
||||||
|
|
||||||
if [ -z "$labels" ]; then
|
if [ -z "$labels" ]; then
|
||||||
|
|
@ -148,8 +174,8 @@ main() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
local args=()
|
local args=()
|
||||||
while IFS= read -r label; do args+=(-f "labels[]=$label"); done <<<"$labels"
|
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
|
||||||
run gh api "repos/$REPO/issues/$PR_NUMBER/labels" "${args[@]}" --silent
|
run forge_labels_add "$PR_NUMBER" "${args[@]}"
|
||||||
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
56
changelog.d/188.md
Normal file
56
changelog.d/188.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
|
||||||
|
the runner's own environment, `forge_client` names the client it needs, and
|
||||||
|
`forge_preflight` refuses loudly before any sweep when the two disagree
|
||||||
|
(#188).
|
||||||
|
- The reconcilers and `labels-scope` run that preflight first, so a
|
||||||
|
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
|
||||||
|
sweep that reads nothing and reports success (#188).
|
||||||
|
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
|
||||||
|
`refs_references`, so "which issues does this PR close" is answered from a
|
||||||
|
PR body rather than from GitHub's GraphQL API (#188).
|
||||||
|
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
|
||||||
|
backends, selected by `forge_select`; no forge branching at the call sites
|
||||||
|
(#188).
|
||||||
|
- The forgejo backend proves each paginated gather complete against the
|
||||||
|
server's `x-total-count` and refuses loudly when it cannot — a missing
|
||||||
|
header is a refusal, not a pass (#188).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
|
||||||
|
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
|
||||||
|
replaced rather than translated; both forges return `number` and `body`
|
||||||
|
from `/pulls` in the same shape (#188).
|
||||||
|
|
||||||
|
- `forge_api` owns the page size, because each forge silently ignores the
|
||||||
|
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
|
||||||
|
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
|
||||||
|
|
||||||
|
- Outstanding review requests are derived from the reviews on the current head
|
||||||
|
rather than from `requested_reviewers`, which Forgejo never clears — read
|
||||||
|
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
|
||||||
|
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
|
||||||
|
so `$label` is a syntax error there and every scope derivation died before
|
||||||
|
reading the config (#188).
|
||||||
|
|
||||||
|
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
|
||||||
|
consumer having read zero facts — measured on `heavy-duty/rig`, where the
|
||||||
|
sweep printed `reconciled.` over an empty PR list and scope reported "no
|
||||||
|
labeler.yml" for a file that exists (#188).
|
||||||
|
|
||||||
|
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
|
||||||
|
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
|
||||||
|
ruling ladder reads the same board on both forges (#188).
|
||||||
|
|
||||||
|
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
|
||||||
|
(HTTP 404); inline review comments come from reviews with
|
||||||
|
`comments_count > 0` (#188).
|
||||||
|
|
||||||
|
- CI installs shellcheck before linting, matching actionlint — the Forgejo
|
||||||
|
runner image does not ship it (#188).
|
||||||
|
|
@ -396,6 +396,13 @@ never before it and never through mixed refs.
|
||||||
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
Both actor lists are whitespace-separated. `triage-actors` names the identities
|
||||||
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly
|
||||||
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
`name|color|description`; blank lines are ignored and extra pipes are refused.
|
||||||
|
|
||||||
|
**Every account in `panel=` must be able to read the repository.** Requesting a
|
||||||
|
review from someone without read access is refused by the forge, not silently
|
||||||
|
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
|
||||||
|
(#188). On a public repo this is satisfied already; on a **private** consumer
|
||||||
|
it is a real failure mode when a panel member is not on the collaborator
|
||||||
|
list, and the sweep will report it rather than sweep blind.
|
||||||
There are no comment lines: every non-blank line must be the `panel=`
|
There are no comment lines: every non-blank line must be the `panel=`
|
||||||
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
setting, the `triage-actors=` setting, or a label row, so `#`-prefixed prose
|
||||||
is a parse failure, not a comment (rig #13's conversion found this the hard
|
is a parse failure, not a comment (rig #13's conversion found this the hard
|
||||||
|
|
|
||||||
79
lib/closes_references.sh
Normal file
79
lib/closes_references.sh
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/closes_references.sh — "which issues does this PR body close?", parsed
|
||||||
|
# here rather than asked of a forge (issue #188, term 3).
|
||||||
|
#
|
||||||
|
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
|
||||||
|
# options, as lib/version.sh and lib/forge.sh do.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
|
||||||
|
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
|
||||||
|
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
|
||||||
|
# it from the other side: a real forgejo-runner job arrives with
|
||||||
|
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
|
||||||
|
# So that call site could not be translated to a Forgejo endpoint — there is
|
||||||
|
# nothing to translate it to. It had to be replaced by a parse this repo
|
||||||
|
# owns, over a field both forges already return:
|
||||||
|
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
|
||||||
|
# /api/v3 and /api/v1 alike (measured on both).
|
||||||
|
#
|
||||||
|
# That the replacement is honest is the point. The sibling half of the same
|
||||||
|
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
|
||||||
|
# `number` and `body` and ran them through refs_references. GraphQL was
|
||||||
|
# buying pagination convenience there, nothing semantic. This file makes the
|
||||||
|
# other half symmetric: one parser this repo controls and can test, for both
|
||||||
|
# link kinds, on both forges.
|
||||||
|
#
|
||||||
|
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
|
||||||
|
# records closing links attached through the pull request's development
|
||||||
|
# sidebar, which live in no body and which no body parse can see. This
|
||||||
|
# family declares its links in the body — that is what BUILDER.md's PR
|
||||||
|
# template asks for — so the delta is zero in practice here. A consumer that
|
||||||
|
# links through the sidebar would see those issues go unclosed by the sweep;
|
||||||
|
# they would need to say so in the body instead.
|
||||||
|
#
|
||||||
|
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
|
||||||
|
# CROSS classifier that keeps rig#112 from ever being read as local #112
|
||||||
|
# (#61). Bash resolves function calls at call time, so the order of sourcing
|
||||||
|
# does not matter; both must simply be defined before closes_references runs.
|
||||||
|
# refs_references depends on it exactly the same way.
|
||||||
|
|
||||||
|
# closes_references — PR body on stdin -> local issue numbers this body
|
||||||
|
# declares it CLOSES, sorted, unique.
|
||||||
|
#
|
||||||
|
# The keyword set is GitHub's documented one, all three verbs in all three
|
||||||
|
# tenses. Matching is case-insensitive because bodies are written by humans
|
||||||
|
# and agents both ("Closes", "closes", "CLOSES").
|
||||||
|
#
|
||||||
|
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
|
||||||
|
# refs_references owns it, and conflating them would make every referenced
|
||||||
|
# issue look closeable, which is the post-merge transition #151 had to be
|
||||||
|
# reopened by hand over.
|
||||||
|
closes_references() {
|
||||||
|
awk '
|
||||||
|
{
|
||||||
|
line = $0
|
||||||
|
lower = tolower(line)
|
||||||
|
# Every occurrence contributes, not just the first: a body that says
|
||||||
|
# "Closes #1. Closes #2." declares two, and binding to the first
|
||||||
|
# occurrence dropped the later ones — the same defect #184 fixed in
|
||||||
|
# blocked_reference_records, kept fixed here by construction.
|
||||||
|
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
|
||||||
|
# BOTH cursors advance together. Advancing only `lower` left the
|
||||||
|
# next match offset indexing the ORIGINAL line, so the second
|
||||||
|
# declaration on a line came back as garbage — caught by the
|
||||||
|
# "two closes on one line" case, which is why it is a case.
|
||||||
|
rest = substr(line, RSTART + RLENGTH)
|
||||||
|
line = rest
|
||||||
|
lower = tolower(rest)
|
||||||
|
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
|
||||||
|
token = rest
|
||||||
|
# Stop at the first thing that cannot be part of a reference, so
|
||||||
|
# "Closes #12, and more prose" yields #12 and not the sentence.
|
||||||
|
sub(/[^[:alnum:]_.\/#-].*/, "", token)
|
||||||
|
print token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' | issue_references \
|
||||||
|
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
|
||||||
|
}
|
||||||
483
lib/forge-forgejo.sh
Normal file
483
lib/forge-forgejo.sh
Normal file
|
|
@ -0,0 +1,483 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
|
||||||
|
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
|
||||||
|
# forgejo; never sourced directly, and never at the same time as the github
|
||||||
|
# backend — they define the same verbs on purpose.
|
||||||
|
#
|
||||||
|
# curl+jq rather than a CLI because that is what the runner has. The image
|
||||||
|
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
|
||||||
|
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
|
||||||
|
|
||||||
|
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
|
||||||
|
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
|
||||||
|
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
|
||||||
|
# it for tests and for anyone driving this outside Actions.
|
||||||
|
forgejo_api_base() {
|
||||||
|
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
|
||||||
|
if [ -z "$base" ]; then
|
||||||
|
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' "${base%/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
|
||||||
|
# testable without a network. Returns the endpoint with this backend's OWN
|
||||||
|
# paging parameters applied.
|
||||||
|
#
|
||||||
|
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
|
||||||
|
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
|
||||||
|
#
|
||||||
|
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
||||||
|
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
||||||
|
#
|
||||||
|
# Each forge silently ignores the other's page-size parameter, answers
|
||||||
|
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
|
||||||
|
# was written GitHub-shaped, so a verbatim port would have swept 30 of
|
||||||
|
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
|
||||||
|
# green, and the same "degraded read that does not report it degraded"
|
||||||
|
# failure class this whole issue exists to kill.
|
||||||
|
#
|
||||||
|
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
|
||||||
|
# boundary once beats fixing nine call sites and trusting the tenth — the
|
||||||
|
# same argument that chose shape C over B, one level down.
|
||||||
|
#
|
||||||
|
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
|
||||||
|
# this instance) whatever you ask for, so asking for more cannot help and
|
||||||
|
# pagination is mandatory rather than an optimisation.
|
||||||
|
forgejo_page_url() {
|
||||||
|
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
|
||||||
|
# Strip any page-size parameter a caller left behind, in either dialect,
|
||||||
|
# rather than trusting that none did: this function is the one place that
|
||||||
|
# decides paging, and a stray per_page= would be exactly the silent
|
||||||
|
# truncation above.
|
||||||
|
local clean="$endpoint"
|
||||||
|
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
||||||
|
case "$clean" in
|
||||||
|
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
|
||||||
|
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
|
||||||
|
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
||||||
|
#
|
||||||
|
# --paginate walks page= until a short page, then PROVES the walk was
|
||||||
|
# complete by comparing what it collected against the server's declared
|
||||||
|
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
|
||||||
|
# header is a loud refusal, not a pass. Header exposure is a server setting
|
||||||
|
# (access-control-expose-headers), and an instance that withholds it would
|
||||||
|
# make the completeness check compare null to a number — the guard itself
|
||||||
|
# degrading silently, which is the failure class re-entering through the
|
||||||
|
# door built to stop it.
|
||||||
|
forge_api() {
|
||||||
|
local paginate=false endpoint="" jqexpr="" have_jq=false
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--paginate) paginate=true ;;
|
||||||
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
||||||
|
-*) ;;
|
||||||
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
||||||
|
|
||||||
|
local base token
|
||||||
|
base="$(forgejo_api_base)" || return 1
|
||||||
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
|
|
||||||
|
local hdr body
|
||||||
|
hdr="$(mktemp)"; body="$(mktemp)"
|
||||||
|
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
|
||||||
|
trap "rm -f '$hdr' '$body'" RETURN
|
||||||
|
|
||||||
|
if [ "$paginate" = false ]; then
|
||||||
|
if ! curl -sS -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||||
|
"$base/$endpoint"; then
|
||||||
|
echo "forge_api: request failed: $endpoint" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
||||||
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Paginated: accumulate into ONE array and apply --jq once at the end.
|
||||||
|
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
|
||||||
|
# shapes every call site here uses, the two are identical, and merging
|
||||||
|
# first is what makes the completeness assert possible at all.
|
||||||
|
local page=1 total="" got=0 n all="[]" pagejson
|
||||||
|
while :; do
|
||||||
|
if ! curl -sS -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" -H 'Accept: application/json' \
|
||||||
|
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
|
||||||
|
echo "forge_api: request failed: $endpoint (page $page)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
forgejo_http_ok "$hdr" "$endpoint" || return 1
|
||||||
|
|
||||||
|
# Re-read on EVERY page, not once (#4712). A board that changes size
|
||||||
|
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
|
||||||
|
# 9 stopped at 4 believing itself whole. A moving total means the read
|
||||||
|
# cannot have been atomic, so it is refused rather than reconciled.
|
||||||
|
local page_total
|
||||||
|
page_total="$(forgejo_total_count "$hdr")" || return 1
|
||||||
|
if [ -z "$total" ]; then
|
||||||
|
total="$page_total"
|
||||||
|
elif [ "$page_total" != "$total" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
|
||||||
|
The collection moved under the walk, so no page set can be proven whole.
|
||||||
|
Refusing rather than reconciling a board that is already out of date.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pagejson="$(cat "$body")"
|
||||||
|
# A 200 whose body is not a collection counted as zero items (#4712),
|
||||||
|
# so an error object or a scalar arriving where a list belongs read as
|
||||||
|
# a complete EMPTY collection whenever the declared total was 0.
|
||||||
|
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_api: '$endpoint' did not return a collection (#188).
|
||||||
|
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
|
||||||
|
Refusing: a body this shim cannot count must not be counted as empty.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
n="$(jq 'length' <<<"$pagejson")"
|
||||||
|
[ "$n" -gt 0 ] || break
|
||||||
|
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
|
||||||
|
got=$((got + n))
|
||||||
|
[ "$got" -lt "$total" ] || break
|
||||||
|
page=$((page + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# The assert. A short read here is the silent-truncation bug arriving by
|
||||||
|
# another route, so it is fatal rather than a warning.
|
||||||
|
if [ "$got" -ne "$total" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
|
||||||
|
Refusing rather than reconciling a partial board: a sweep over part of the
|
||||||
|
queue that reports success is the failure this shim exists to prevent.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_total_count <header-file> — the declared size of the collection.
|
||||||
|
# Absent is fatal (#4699): without it the completeness assert cannot run,
|
||||||
|
# and an assert that cannot run must not silently pass.
|
||||||
|
forgejo_total_count() {
|
||||||
|
local hdr="$1" total
|
||||||
|
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
|
||||||
|
if [ -z "$total" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
|
||||||
|
The header is exposed by a server setting (access-control-expose-headers).
|
||||||
|
Refusing: an unprovable read must not be reported as a whole one.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
|
||||||
|
# not-a-number` used to sail through and become the bound the walk was
|
||||||
|
# compared against — a guard whose own input was never checked.
|
||||||
|
case "$total" in
|
||||||
|
'' | *[!0-9]*)
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
|
||||||
|
Refusing: the completeness bound must be a number, or the assert that
|
||||||
|
uses it proves nothing.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s\n' "$total"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_http_ok <header-file> <endpoint> — a non-2xx is named, not
|
||||||
|
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
|
||||||
|
# and -f would throw away the body that says why.
|
||||||
|
forgejo_http_ok() {
|
||||||
|
local hdr="$1" endpoint="$2" code
|
||||||
|
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
|
||||||
|
case "$code" in
|
||||||
|
2*) return 0 ;;
|
||||||
|
*)
|
||||||
|
echo "forge_api: HTTP $code from '$endpoint'" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the verbs the reconcilers use, over /api/v1 --------------------------
|
||||||
|
# Three asymmetries with gh, all measured against this instance on
|
||||||
|
# 2026-08-02 using a scratch repo (never a live board):
|
||||||
|
#
|
||||||
|
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
|
||||||
|
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
|
||||||
|
# DELETE /issues/{n}/labels/149 -> 204
|
||||||
|
# So a removal must resolve name -> id first. gh hides this; the shim
|
||||||
|
# cannot.
|
||||||
|
#
|
||||||
|
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
|
||||||
|
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
|
||||||
|
# a read-modify-write rather than a delete.
|
||||||
|
#
|
||||||
|
# 3. There is no statusCheckRollup. The portable equivalent is the
|
||||||
|
# combined commit status, GET /commits/{sha}/status, which returns
|
||||||
|
# {state, statuses[]}.
|
||||||
|
|
||||||
|
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
|
||||||
|
# call site that needs it. Paginated through forge_api, so a repo with more
|
||||||
|
# than one page of labels cannot silently lose the tail (#188).
|
||||||
|
forgejo_label_ids() {
|
||||||
|
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
|
||||||
|
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
|
||||||
|
forge_issue_edit() {
|
||||||
|
local n="${1:?forge_issue_edit: number required}"
|
||||||
|
shift
|
||||||
|
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
|
||||||
|
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
|
||||||
|
# given to `gh`, which fails on a flag it does not know; dropping it here
|
||||||
|
# instead would turn a port typo into a green no-op — a mutation that
|
||||||
|
# silently did not happen, which is precisely this issue's failure class
|
||||||
|
# arriving inside the fix for it.
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--add-label | --remove-label | --add-assignee | --remove-assignee)
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
echo "forge_issue_edit: $1 requires a value (#188)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
IFS=, read -ra v <<<"$2"
|
||||||
|
case "$1" in
|
||||||
|
--add-label) add_labels+=("${v[@]}") ;;
|
||||||
|
--remove-label) rm_labels+=("${v[@]}") ;;
|
||||||
|
--add-assignee) add_assignees+=("${v[@]}") ;;
|
||||||
|
--remove-assignee) rm_assignees+=("${v[@]}") ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#add_labels[@]}" -gt 0 ]; then
|
||||||
|
local payload
|
||||||
|
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
|
||||||
|
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
||||||
|
local ids id name
|
||||||
|
ids="$(forgejo_label_ids)" || return 1
|
||||||
|
for name in "${rm_labels[@]}"; do
|
||||||
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
||||||
|
# A label the repo does not have is not an error: the reconcilers call
|
||||||
|
# --remove-label unconditionally to converge state, and gh's own
|
||||||
|
# behaviour there is a no-op.
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
|
||||||
|
local current want payload
|
||||||
|
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
|
||||||
|
want="$(
|
||||||
|
{
|
||||||
|
printf '%s\n' "$current"
|
||||||
|
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
|
||||||
|
} | grep -v '^$' | sort -u
|
||||||
|
)"
|
||||||
|
if [ "${#rm_assignees[@]}" -gt 0 ]; then
|
||||||
|
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
|
||||||
|
fi
|
||||||
|
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
|
||||||
|
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_issue_comment() {
|
||||||
|
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
||||||
|
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_pr_list() {
|
||||||
|
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
||||||
|
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
||||||
|
# is mapped into the node shape checks_state already parses, so the decision
|
||||||
|
# code is untouched.
|
||||||
|
forge_pr_view() {
|
||||||
|
local n="${1:?forge_pr_view: number required}" pr sha status
|
||||||
|
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
||||||
|
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
||||||
|
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
||||||
|
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
||||||
|
jq -n --argjson pr "$pr" --argjson st "$status" '
|
||||||
|
{
|
||||||
|
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
||||||
|
elif $pr.mergeable == false then "CONFLICTING"
|
||||||
|
else "UNKNOWN" end),
|
||||||
|
statusCheckRollup: [
|
||||||
|
$st.statuses[]? | {
|
||||||
|
__typename: "StatusContext",
|
||||||
|
context: .context,
|
||||||
|
state: (.status | ascii_upcase),
|
||||||
|
# checks_state groups repeated contexts and takes the NEWEST by
|
||||||
|
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
|
||||||
|
# winner would be decided by incidental array order, so a stale
|
||||||
|
# re-run could outrank the live verdict (#4743). The combined
|
||||||
|
# status carries both fields; measured on this instance.
|
||||||
|
createdAt: .created_at,
|
||||||
|
completedAt: .updated_at
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
||||||
|
|
||||||
|
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
|
||||||
|
# bootstrap_labels creates every declared label on every workflow_dispatch, so
|
||||||
|
# the second dispatch must update rather than conflict; a plain POST onto an
|
||||||
|
# existing name aborts the bootstrap under set -e.
|
||||||
|
forge_label_create() {
|
||||||
|
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
|
||||||
|
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
|
||||||
|
ids="$(forgejo_label_ids)" || return 1
|
||||||
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
||||||
|
if [ -n "$id" ]; then
|
||||||
|
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
|
||||||
|
else
|
||||||
|
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_delete() {
|
||||||
|
local name="${1:?}" ids id
|
||||||
|
ids="$(forgejo_label_ids)" || return 1
|
||||||
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
||||||
|
[ -n "$id" ] || return 0
|
||||||
|
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
|
||||||
|
# here so a non-2xx is named rather than swallowed, the same contract
|
||||||
|
# forgejo_http_ok gives reads.
|
||||||
|
forgejo_write() {
|
||||||
|
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
|
||||||
|
base="$(forgejo_api_base)" || return 1
|
||||||
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
|
hdr="$(mktemp)"; body="$(mktemp)"
|
||||||
|
if [ -n "$payload" ]; then
|
||||||
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" -H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" "$base/$endpoint"
|
||||||
|
else
|
||||||
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" "$base/$endpoint"
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
echo "forge: $method $endpoint failed to send" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
|
||||||
|
head -c 300 "$body" >&2; echo >&2
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cat "$body"
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
|
||||||
|
# the github twin). POST /issues/{n}/labels adds the named labels and removes
|
||||||
|
# nothing, and it takes NAMES — measured, unlike the removal path, which
|
||||||
|
# needs ids.
|
||||||
|
forge_labels_add() {
|
||||||
|
local n="${1:?forge_labels_add: number required}"
|
||||||
|
shift
|
||||||
|
[ "$#" -gt 0 ] || return 0
|
||||||
|
forgejo_write POST "repos/$REPO/issues/$n/labels" \
|
||||||
|
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
||||||
|
#
|
||||||
|
# This endpoint DOES exist here, contrary to an earlier reading of mine
|
||||||
|
# (#4698) which recorded requested_reviewers as having no sub-resource at
|
||||||
|
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
|
||||||
|
# GET, so a GET probe answers 404 — and a POST naming a user who does not
|
||||||
|
# exist answers 404 as well, for a different reason. Measured on a scratch
|
||||||
|
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
|
||||||
|
# read"), and 201 once they have it.
|
||||||
|
#
|
||||||
|
# The READ stays retired regardless (term 4): the field is stale here even on
|
||||||
|
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
|
||||||
|
# current head SHA. It is the write that has an answer.
|
||||||
|
forge_request_reviewer() {
|
||||||
|
local n="${1:?}" user="${2:?}"
|
||||||
|
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
|
||||||
|
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_timeline <n> — JSON array of timeline events projected into the
|
||||||
|
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
|
||||||
|
#
|
||||||
|
# | | GitHub | Forgejo |
|
||||||
|
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
|
||||||
|
# | add vs remove | the two event names | .body "1" / "" |
|
||||||
|
# | actor | .actor.login (no .user) | .user.login (no .actor) |
|
||||||
|
#
|
||||||
|
# Status is captured BEFORE jq so an unreadable read cannot report as an
|
||||||
|
# empty timeline — the two states the ruling ladder must tell apart (#4853).
|
||||||
|
forge_timeline() {
|
||||||
|
local n="${1:?forge_timeline: number required}" raw
|
||||||
|
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
|
||||||
|
jq '
|
||||||
|
[.[]
|
||||||
|
| select(.type == "label")
|
||||||
|
| {
|
||||||
|
event: (if .body == "1" then "labeled" else "unlabeled" end),
|
||||||
|
actor: {login: (.user.login // "")},
|
||||||
|
label: {name: (.label.name // "")},
|
||||||
|
created_at: .created_at
|
||||||
|
}
|
||||||
|
]
|
||||||
|
' <<<"$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
|
||||||
|
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
|
||||||
|
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
|
||||||
|
# with comments_count > 0 are fetched, so a board with none costs zero
|
||||||
|
# extra requests.
|
||||||
|
forge_pr_activity() {
|
||||||
|
local n="${1:?forge_pr_activity: number required}" reviews rid
|
||||||
|
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
||||||
|
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
||||||
|
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
|
||||||
|
while IFS= read -r rid; do
|
||||||
|
[ -n "$rid" ] || continue
|
||||||
|
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
|
||||||
|
--jq '.[].created_at' || return 1
|
||||||
|
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
|
||||||
|
}
|
||||||
158
lib/forge-github.sh
Normal file
158
lib/forge-github.sh
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
|
||||||
|
# lib/forge.sh when forge_detect says github; never at the same time as the
|
||||||
|
# forgejo backend — they define the same verbs on purpose.
|
||||||
|
#
|
||||||
|
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
|
||||||
|
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
|
||||||
|
# way to keep that true is for every verb here to be a thin pass-through to
|
||||||
|
# the `gh` invocation the call site used before the port. No behaviour is
|
||||||
|
# added, fixed or tidied on this path; anything that looks like an
|
||||||
|
# improvement here is a regression risk against a forge nobody is currently
|
||||||
|
# reporting bugs on.
|
||||||
|
|
||||||
|
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
||||||
|
#
|
||||||
|
# The one deliberate difference from a pure pass-through: the caller no
|
||||||
|
# longer names a page size, because the page-size parameter is not portable
|
||||||
|
# and is therefore the backend's to own (#188).
|
||||||
|
#
|
||||||
|
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
||||||
|
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
||||||
|
#
|
||||||
|
# Both answer HTTP 200 either way, so a call site that names one is a silent
|
||||||
|
# truncation waiting for the other forge. per_page=100 is injected here —
|
||||||
|
# exactly what the call sites said before — so the GitHub path is unchanged
|
||||||
|
# in behaviour while the parameter stops being a call-site concern.
|
||||||
|
forge_api() {
|
||||||
|
local paginate=false endpoint="" jqexpr="" have_jq=false
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--paginate) paginate=true ;;
|
||||||
|
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
||||||
|
-*) ;;
|
||||||
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
||||||
|
|
||||||
|
if [ "$paginate" = true ]; then
|
||||||
|
endpoint="$(github_page_url "$endpoint")"
|
||||||
|
if [ "$have_jq" = true ]; then
|
||||||
|
gh api --paginate "$endpoint" --jq "$jqexpr"
|
||||||
|
else
|
||||||
|
gh api --paginate "$endpoint"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$have_jq" = true ]; then
|
||||||
|
gh api "$endpoint" --jq "$jqexpr"
|
||||||
|
else
|
||||||
|
gh api "$endpoint"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# github_page_url <endpoint> — pure, so the page-size contract is testable
|
||||||
|
# without a network. Strips any page-size parameter a caller left behind in
|
||||||
|
# either dialect, then applies GitHub's own.
|
||||||
|
github_page_url() {
|
||||||
|
local endpoint="${1:?github_page_url: endpoint required}" clean
|
||||||
|
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
||||||
|
case "$clean" in
|
||||||
|
*\?) printf '%sper_page=100\n' "$clean" ;;
|
||||||
|
*\?*) printf '%s&per_page=100\n' "$clean" ;;
|
||||||
|
*) printf '%s?per_page=100\n' "$clean" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
|
||||||
|
# Every one of these is the exact `gh` invocation the call site carried
|
||||||
|
# before the port. Term 5 is kept by making this file boring.
|
||||||
|
|
||||||
|
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
|
||||||
|
# or a PR (gh treats them interchangeably, and so do the call sites).
|
||||||
|
forge_issue_edit() {
|
||||||
|
local n="${1:?forge_issue_edit: number required}"
|
||||||
|
shift
|
||||||
|
gh issue edit "$n" -R "$REPO" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_issue_comment <n> <body>
|
||||||
|
forge_issue_comment() {
|
||||||
|
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
||||||
|
gh issue comment "$n" -R "$REPO" --body "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_list — open PR numbers, one per line. Note this used
|
||||||
|
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
|
||||||
|
# URL-parameter strip could have caught, so it moves behind the shim with
|
||||||
|
# the rest (#188).
|
||||||
|
forge_pr_list() {
|
||||||
|
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
|
||||||
|
# with the reason on stderr. `gh pr view` rather than the REST PR object:
|
||||||
|
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
|
||||||
|
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
||||||
|
forge_pr_view() {
|
||||||
|
local n="${1:?forge_pr_view: number required}"
|
||||||
|
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_label_list — every label name in the repo.
|
||||||
|
forge_label_list() {
|
||||||
|
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_create() {
|
||||||
|
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
||||||
|
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_delete() {
|
||||||
|
local name="${1:?}"
|
||||||
|
gh label delete "$name" -R "$REPO" --yes
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
|
||||||
|
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
|
||||||
|
# labeler action computed (labels-at-job-start ∪ derived) and PUT the whole
|
||||||
|
# set, so a label applied while the job ran was silently removed. This is the
|
||||||
|
# raw POST, which adds the named labels, ignores ones already present, and
|
||||||
|
# removes nothing — a concurrent label survives by construction.
|
||||||
|
forge_labels_add() {
|
||||||
|
local n="${1:?forge_labels_add: number required}" args=() label
|
||||||
|
shift
|
||||||
|
for label in "$@"; do args+=(-f "labels[]=$label"); done
|
||||||
|
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
||||||
|
forge_request_reviewer() {
|
||||||
|
local n="${1:?}" user="${2:?}"
|
||||||
|
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
|
||||||
|
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
|
||||||
|
# pass-through: that shape is what the forge already returns (#188 batch).
|
||||||
|
# Callers must capture the status of THIS function before piping into jq —
|
||||||
|
# a pipeline's status is the last command's, so `forge_timeline | jq`
|
||||||
|
# collapses an unreadable timeline into an empty one (#4853).
|
||||||
|
forge_timeline() {
|
||||||
|
local n="${1:?forge_timeline: number required}"
|
||||||
|
forge_api --paginate "repos/$REPO/issues/$n/timeline"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
|
||||||
|
# (issue comments, inline review comments, commits). GitHub serves the
|
||||||
|
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
|
||||||
|
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
|
||||||
|
forge_pr_activity() {
|
||||||
|
local n="${1:?forge_pr_activity: number required}"
|
||||||
|
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
|
||||||
|
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
|
||||||
|
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
|
||||||
|
}
|
||||||
218
lib/forge.sh
Normal file
218
lib/forge.sh
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/forge.sh — one forge abstraction, two backends (issue #188).
|
||||||
|
#
|
||||||
|
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
|
||||||
|
# own shell options, exactly as lib/version.sh does. This file is the
|
||||||
|
# selector only; the backends live beside it in lib/forge-github.sh and
|
||||||
|
# lib/forge-forgejo.sh, and nothing here talks to a network.
|
||||||
|
#
|
||||||
|
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
|
||||||
|
# all the way down — 61 runtime call sites, no indirection, no forge check.
|
||||||
|
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
|
||||||
|
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
|
||||||
|
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
|
||||||
|
#
|
||||||
|
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
|
||||||
|
# to derive" — the file exists (HTTP 200)
|
||||||
|
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
|
||||||
|
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
||||||
|
#
|
||||||
|
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
|
||||||
|
# own blind-sweep warning (#96) could not fire, because it counts unreadable
|
||||||
|
# PRs against a list `gh pr list` never produced — and a process
|
||||||
|
# substitution's failure does not trip set -e, so `total` stayed 0 and the
|
||||||
|
# sweep called itself reconciled. rig run 979 is the log.
|
||||||
|
#
|
||||||
|
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
|
||||||
|
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
|
||||||
|
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
|
||||||
|
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
|
||||||
|
# prints nothing, exits into the same empty loop) and all three actions go
|
||||||
|
# green while reading nothing. That is this repo's own doctrine — an
|
||||||
|
# unreadable rollup reads as "nothing is failing" — being violated by the
|
||||||
|
# repo that wrote it.
|
||||||
|
#
|
||||||
|
# So: the forge is decided ONCE, before any sweep, and a client that cannot
|
||||||
|
# speak it refuses loudly. Never "probably github".
|
||||||
|
|
||||||
|
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
|
||||||
|
# tell. Order matters and every signal below was measured, not read from
|
||||||
|
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
|
||||||
|
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
|
||||||
|
# runner's is the control.
|
||||||
|
#
|
||||||
|
# The trap that makes this non-obvious: **the Forgejo runner populates the
|
||||||
|
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
|
||||||
|
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
|
||||||
|
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
|
||||||
|
# precisely the bug. What actually differs:
|
||||||
|
#
|
||||||
|
# signal GitHub Forgejo (measured)
|
||||||
|
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
|
||||||
|
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
|
||||||
|
# GITEA_ACTIONS (unset) true
|
||||||
|
#
|
||||||
|
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
|
||||||
|
# forge telling us the two `gh api graphql` sites #188 retired can never
|
||||||
|
# work here. It is deliberately NOT a detection signal, though: an empty
|
||||||
|
# variable is also what a hand-rolled harness leaves behind, and a signal
|
||||||
|
# that fires on absence is a signal that fires by accident.
|
||||||
|
forge_detect() {
|
||||||
|
# 1. The explicit override outranks every probe — the escape hatch for a
|
||||||
|
# forge this file has not met, and the handle the tests drive. A typo
|
||||||
|
# in it is fatal on purpose: the operator said something and it was
|
||||||
|
# wrong, and falling through to a probe that guesses right by accident
|
||||||
|
# would hide that until the guess was wrong too.
|
||||||
|
if [ -n "${CEREMONY_FORGE:-}" ]; then
|
||||||
|
case "$CEREMONY_FORGE" in
|
||||||
|
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
|
||||||
|
*)
|
||||||
|
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
|
||||||
|
# hand-set GITHUB_API_URL might not be, so it is read first.
|
||||||
|
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
|
||||||
|
printf 'forgejo\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
|
||||||
|
# Enterprise Server alike — GHES is a github backend on a non-github.com
|
||||||
|
# host, and routing it to the forgejo backend would regress term 5's
|
||||||
|
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
|
||||||
|
# serves.
|
||||||
|
case "${GITHUB_API_URL:-}" in
|
||||||
|
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
|
||||||
|
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
|
||||||
|
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 4. Last resort, the server host. Only github.com itself is conclusive
|
||||||
|
# here: a bare hostname says nothing about which API it serves.
|
||||||
|
case "${GITHUB_SERVER_URL:-}" in
|
||||||
|
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
|
||||||
|
# reinstates the exact blind sweep this file exists to end. Name what
|
||||||
|
# was inspected and the escape hatch, so the log answers "why" without
|
||||||
|
# a second run (#101 D5, one layer up: report, do not diagnose).
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_detect: cannot determine which forge this is — refusing to guess (#188).
|
||||||
|
GITHUB_API_URL='${GITHUB_API_URL:-}'
|
||||||
|
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
|
||||||
|
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
|
||||||
|
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Where the backends live. Captured at source time, not call time: a
|
||||||
|
# function that resolves BASH_SOURCE later would resolve its own file, not
|
||||||
|
# this one.
|
||||||
|
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# forge_select [forge] — source the backend for this forge, so the forge_*
|
||||||
|
# verbs exist. Exactly one backend is ever loaded; both define the same
|
||||||
|
# names, which is what keeps the branching out of the 61 call sites (term 1)
|
||||||
|
# and the single-forge assumption from growing back.
|
||||||
|
#
|
||||||
|
# Idempotent, because the actions call it once and the tests call it per
|
||||||
|
# case. Pass a forge explicitly to load a specific backend; omit it and the
|
||||||
|
# environment decides via forge_detect.
|
||||||
|
forge_select() {
|
||||||
|
local forge="${1:-}"
|
||||||
|
if [ -z "$forge" ]; then
|
||||||
|
forge="$(forge_detect)" || return 1
|
||||||
|
fi
|
||||||
|
case "$forge" in
|
||||||
|
github | forgejo) ;;
|
||||||
|
*)
|
||||||
|
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
|
||||||
|
# Read by callers and tests to assert which backend is loaded, so the
|
||||||
|
# choice is inspectable rather than implied by which functions exist.
|
||||||
|
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
|
||||||
|
FORGE="$forge"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_client <forge> — print the client that backend requires.
|
||||||
|
#
|
||||||
|
# github -> gh the current call set, extracted 1:1 (term 5)
|
||||||
|
# forgejo -> rest /api/v1 over curl+jq
|
||||||
|
#
|
||||||
|
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
|
||||||
|
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
|
||||||
|
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
|
||||||
|
# `stoke` on PATH. That second absence is what retired option A from the
|
||||||
|
# ruling: porting the call sites to the stoke CLI would have put a binary
|
||||||
|
# on the critical path that the runner does not have and that would need
|
||||||
|
# installing before every job.
|
||||||
|
forge_client() {
|
||||||
|
case "${1:?forge_client: forge required}" in
|
||||||
|
github) printf 'gh\n' ;;
|
||||||
|
forgejo) printf 'rest\n' ;;
|
||||||
|
*)
|
||||||
|
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
|
||||||
|
# and proves the client can speak it, or exits non-zero with a named reason.
|
||||||
|
#
|
||||||
|
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
|
||||||
|
# how a call site that still hard-codes `gh` announces itself honestly while
|
||||||
|
# the backends are being ported. Two checks run, in order:
|
||||||
|
#
|
||||||
|
# 1. the declaration, when made, must match what this forge needs;
|
||||||
|
# 2. that client's binaries must actually be on PATH — checked whether or
|
||||||
|
# not a declaration was made, because a call site that declares the
|
||||||
|
# right client on a runner that lacks it is still a blind sweep waiting
|
||||||
|
# to happen.
|
||||||
|
forge_preflight() {
|
||||||
|
local forge want
|
||||||
|
forge="$(forge_detect)" || return 1
|
||||||
|
want="$(forge_client "$forge")" || return 1
|
||||||
|
|
||||||
|
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
|
||||||
|
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
|
||||||
|
and has no GraphQL surface at all. Pointing one at the other does not
|
||||||
|
fail usefully — it reads nothing and reports success.
|
||||||
|
This forge needs the '$want' client.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Then prove the tools are actually here — declared or not. A missing
|
||||||
|
# binary is the rig failure verbatim, "line 692: gh: command not found",
|
||||||
|
# and it must be a refusal before the sweep, not a 127 halfway through
|
||||||
|
# one. Checked on BOTH paths deliberately: a call site that declares the
|
||||||
|
# right client on a runner that lacks it is still a blind sweep waiting
|
||||||
|
# to happen.
|
||||||
|
local missing_bins=() bin
|
||||||
|
case "$want" in
|
||||||
|
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
|
||||||
|
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
|
||||||
|
esac
|
||||||
|
if [ "${#missing_bins[@]}" -gt 0 ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
|
||||||
|
Refusing before the sweep: a reconciler that cannot read the board must
|
||||||
|
not report that it reconciled one.
|
||||||
|
EOF
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
@ -208,13 +208,20 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# skips BOTH checks — the nudge's specified content links the escalation
|
# skips BOTH checks — the nudge's specified content links the escalation
|
||||||
# comment, which only these facts identify, and half-verdicts on half-read
|
# comment, which only these facts identify, and half-verdicts on half-read
|
||||||
# facts is the exact shape the reconciler's standing rule forbids.
|
# facts is the exact shape the reconciler's standing rule forbids.
|
||||||
local flags newest setter labeled_at labeled_epoch
|
# forge_timeline projects both forges into the GitHub event shape
|
||||||
if ! flags="$(gh api --paginate "repos/$REPO/issues/$n/timeline" \
|
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
|
||||||
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
# status is the last command's, so `forge_timeline | jq` would collapse
|
||||||
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then
|
# an unreadable timeline into an empty one — the two states this function
|
||||||
|
# exists to tell apart (#188 / #4853).
|
||||||
|
local flags newest setter labeled_at labeled_epoch timeline
|
||||||
|
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
|
||||||
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
log "#$n: ruling timeline unreadable — no verdict invented this pass"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
flags="$(jq -r '
|
||||||
|
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
|
||||||
|
| [.actor.login, .created_at] | @tsv
|
||||||
|
' <<<"$timeline")"
|
||||||
if [ -z "$flags" ]; then
|
if [ -z "$flags" ]; then
|
||||||
# The label is on the item but no labeled event is visible (a timeline
|
# The label is on the item but no labeled event is visible (a timeline
|
||||||
# hiccup, or an import). Same treatment as unreadable: do nothing.
|
# hiccup, or an import). Same treatment as unreadable: do nothing.
|
||||||
|
|
@ -230,7 +237,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# the whole file is line-oriented, so the row format stays TSV and the
|
# the whole file is line-oriented, so the row format stays TSV and the
|
||||||
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
# body is decoded at its points of use (#73). Do not switch rows to JSON.
|
||||||
local comments
|
local comments
|
||||||
if ! comments="$(gh api --paginate "repos/$REPO/issues/$n/comments" \
|
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
|
||||||
--jq '.[] | [.user.login, .created_at, .html_url,
|
--jq '.[] | [.user.login, .created_at, .html_url,
|
||||||
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
|
||||||
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
log "#$n: ruling comments unreadable — no verdict invented this pass"
|
||||||
|
|
@ -268,7 +275,7 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
|
||||||
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
|
||||||
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
|
||||||
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
|
||||||
run gh issue comment "$n" -R "$REPO" --body "$RULING_BARE_MARKER
|
run forge_issue_comment "$n" "$RULING_BARE_MARKER
|
||||||
The ruling flag on this item was set by @$setter with no accompanying
|
The ruling flag on this item was set by @$setter with no accompanying
|
||||||
escalation comment. Setting it requires the escalation contract — the
|
escalation comment. Setting it requires the escalation contract — the
|
||||||
**question**, the **options**, and a **recommendation** — posted by the
|
**question**, the **options**, and a **recommendation** — posted by the
|
||||||
|
|
@ -299,7 +306,7 @@ still owed." >/dev/null
|
||||||
if [ "$shape" != SHAPED ] \
|
if [ "$shape" != SHAPED ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
|
||||||
local missing="${shape#MALFORMED }"
|
local missing="${shape#MALFORMED }"
|
||||||
run gh issue comment "$n" -R "$REPO" --body "$RULING_SHAPE_MARKER
|
run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
|
||||||
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
|
||||||
is missing required field labels: **$missing**. The contract's shape is
|
is missing required field labels: **$missing**. The contract's shape is
|
||||||
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
|
||||||
|
|
@ -327,7 +334,7 @@ enforced." >/dev/null
|
||||||
esac
|
esac
|
||||||
if [ "$rung" = RUNG12 ] \
|
if [ "$rung" = RUNG12 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
|
||||||
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG12_MARKER
|
run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
|
||||||
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
|
||||||
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -342,7 +349,7 @@ reset on activity; this comment fires once per flag episode." >/dev/null
|
||||||
fi
|
fi
|
||||||
if [ "$rung" = RUNG24 ] \
|
if [ "$rung" = RUNG24 ] \
|
||||||
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
|
||||||
run gh issue comment "$n" -R "$REPO" --body "$RULING_RUNG24_MARKER
|
run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
|
||||||
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
|
||||||
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
|
||||||
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
|
||||||
|
|
@ -375,7 +382,7 @@ timer." >/dev/null
|
||||||
else
|
else
|
||||||
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
|
||||||
fi
|
fi
|
||||||
run gh issue comment "$n" -R "$REPO" --body "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
|
||||||
|
|
||||||
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
|
||||||
|
|
||||||
|
|
|
||||||
87
test/closes_references.test.sh
Normal file
87
test/closes_references.test.sh
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contract tests for lib/closes_references.sh (issue #188, term 3).
|
||||||
|
# set -u, not -e: failing commands are behavior for the harness to inspect.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=test/harness.sh
|
||||||
|
. "$ROOT/test/harness.sh"
|
||||||
|
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
|
||||||
|
# calls it, exactly as refs_references does.
|
||||||
|
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
||||||
|
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
# shellcheck source=lib/closes_references.sh
|
||||||
|
. "$ROOT/lib/closes_references.sh"
|
||||||
|
|
||||||
|
# closes <want-newline-separated> <body> — the parse of <body> is exactly
|
||||||
|
# <want>. Exact, not substring: "12" is contained in "123".
|
||||||
|
closes() {
|
||||||
|
local want="$1" body="$2" got
|
||||||
|
got="$(printf '%s' "$body" | closes_references)"
|
||||||
|
[ "$got" = "$want" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- the three verbs, the three tenses ----------------------------------
|
||||||
|
# GitHub's documented keyword set. All of them, because a body that says
|
||||||
|
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
|
||||||
|
# transition, not a loud one.
|
||||||
|
|
||||||
|
check "closes" 0 "" closes 1 'Closes #1'
|
||||||
|
check "close" 0 "" closes 1 'Close #1'
|
||||||
|
check "closed" 0 "" closes 1 'Closed #1'
|
||||||
|
check "fixes" 0 "" closes 2 'Fixes #2'
|
||||||
|
check "fix" 0 "" closes 2 'Fix #2'
|
||||||
|
check "fixed" 0 "" closes 2 'Fixed #2'
|
||||||
|
check "resolves" 0 "" closes 3 'Resolves #3'
|
||||||
|
check "resolve" 0 "" closes 3 'Resolve #3'
|
||||||
|
check "resolved" 0 "" closes 3 'Resolved #3'
|
||||||
|
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
|
||||||
|
check "lowercase" 0 "" closes 4 'closes #4'
|
||||||
|
check "colon form" 0 "" closes 5 'Closes: #5'
|
||||||
|
|
||||||
|
# --- Refs is NOT a closing link -----------------------------------------
|
||||||
|
# The relation this file must not swallow. refs_references owns Refs, and
|
||||||
|
# conflating them makes every referenced issue look closeable — the
|
||||||
|
# post-merge transition #151 was reopened by hand over exactly that
|
||||||
|
# distinction.
|
||||||
|
|
||||||
|
check "Refs is not a close" 0 "" closes '' 'Refs #7'
|
||||||
|
check "Refs and Closes in one body keeps only the close" 0 "" \
|
||||||
|
closes 8 $'Refs #7\nCloses #8'
|
||||||
|
|
||||||
|
# --- cross-repo references stay out (#61) -------------------------------
|
||||||
|
# rig#112 must never be read as local #112. The classifier is shared with
|
||||||
|
# refs_references precisely so this rule has one implementation.
|
||||||
|
|
||||||
|
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
|
||||||
|
check "owner-qualified reference is not local" 0 "" \
|
||||||
|
closes '' 'Closes heavy-duty/rig#112'
|
||||||
|
check "a local and a cross reference keep only the local" 0 "" \
|
||||||
|
closes 9 $'Closes rig#112\nCloses #9'
|
||||||
|
|
||||||
|
# --- every occurrence contributes ---------------------------------------
|
||||||
|
# Binding to the first occurrence is the defect #184 fixed in
|
||||||
|
# blocked_reference_records; this parser must not reintroduce it.
|
||||||
|
|
||||||
|
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
|
||||||
|
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
|
||||||
|
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
|
||||||
|
|
||||||
|
# --- prose must not be swallowed ----------------------------------------
|
||||||
|
|
||||||
|
check "trailing prose is not part of the reference" 0 "" \
|
||||||
|
closes 12 'Closes #12, and adds the guard'
|
||||||
|
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
|
||||||
|
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
|
||||||
|
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
|
||||||
|
# "unclosed" contains "close" — a naive word match would fire on it.
|
||||||
|
check "a word merely containing a verb does not fire" 0 "" \
|
||||||
|
closes '' 'This left #15 unclosed'
|
||||||
|
|
||||||
|
# --- the shapes a real PR body carries ----------------------------------
|
||||||
|
|
||||||
|
check "the template's leading declaration" 0 "" \
|
||||||
|
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
|
||||||
|
check "an empty body yields nothing" 0 "" closes '' ''
|
||||||
|
|
||||||
|
summary
|
||||||
|
|
@ -10,6 +10,12 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
|
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
||||||
|
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
||||||
|
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
forge_select github
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
|
|
||||||
|
|
|
||||||
619
test/forge-backends.test.sh
Normal file
619
test/forge-backends.test.sh
Normal file
|
|
@ -0,0 +1,619 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contract tests for lib/forge-github.sh and lib/forge-forgejo.sh
|
||||||
|
# (issue #188, term 1). set -u, not -e.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=test/harness.sh
|
||||||
|
. "$ROOT/test/harness.sh"
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
eq() {
|
||||||
|
local want="$1" got
|
||||||
|
shift
|
||||||
|
got="$("$@")" || return 1
|
||||||
|
[ "$got" = "$want" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- forge_select: exactly one backend, chosen deliberately -------------
|
||||||
|
|
||||||
|
check "select github loads the github backend" 0 "" \
|
||||||
|
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select github; declare -f github_page_url >/dev/null'
|
||||||
|
check "select forgejo loads the forgejo backend" 0 "" \
|
||||||
|
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select forgejo; declare -f forgejo_page_url >/dev/null'
|
||||||
|
check "select refuses an unknown forge" 1 "unknown forge" \
|
||||||
|
bash -c '. '"$ROOT"'/lib/forge.sh; forge_select gitlab'
|
||||||
|
# shellcheck disable=SC2016 # $FORGE expands in the isolated bash -c process
|
||||||
|
check "select with no argument reads the environment" 0 "" \
|
||||||
|
bash -c 'CEREMONY_FORGE=forgejo; . '"$ROOT"'/lib/forge.sh; forge_select; [ "$FORGE" = forgejo ]'
|
||||||
|
|
||||||
|
# --- the page-size contract, both dialects ------------------------------
|
||||||
|
# The trap, measured 2026-08-02: each forge silently ignores the OTHER's
|
||||||
|
# page-size parameter and answers HTTP 200 with fewer items.
|
||||||
|
#
|
||||||
|
# ?per_page=100 GitHub 100 Forgejo 30 (ignored)
|
||||||
|
# ?limit=100 GitHub 30 Forgejo 50 (capped)
|
||||||
|
#
|
||||||
|
# So no call site names one, and these two functions are the only places
|
||||||
|
# that decide. Pure on purpose: the contract is testable without a network.
|
||||||
|
|
||||||
|
. "$ROOT/lib/forge-github.sh"
|
||||||
|
. "$ROOT/lib/forge-forgejo.sh"
|
||||||
|
|
||||||
|
check "github: a bare path gets a query" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?per_page=100' github_page_url 'repos/o/r/issues'
|
||||||
|
check "github: an existing query is preserved" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open'
|
||||||
|
check "forgejo: a bare path gets a query" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues' 1
|
||||||
|
check "forgejo: an existing query is preserved" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&limit=50&page=2' forgejo_page_url 'repos/o/r/issues?state=open' 2
|
||||||
|
|
||||||
|
# A caller that names a page size anyway must not be able to reintroduce the
|
||||||
|
# truncation — the parameter is stripped in BOTH dialects, on both backends,
|
||||||
|
# because the whole point is that the boundary decides and the call site
|
||||||
|
# cannot override it by accident.
|
||||||
|
check "github strips a stray per_page" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&per_page=30'
|
||||||
|
check "github strips a stray limit" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&per_page=100' github_page_url 'repos/o/r/issues?state=open&limit=100'
|
||||||
|
check "forgejo strips a stray per_page" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&per_page=100' 1
|
||||||
|
check "forgejo strips a stray limit" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?state=open&limit=50&page=1' forgejo_page_url 'repos/o/r/issues?state=open&limit=100' 1
|
||||||
|
check "stripping the only parameter leaves a clean query" 0 "" \
|
||||||
|
eq 'repos/o/r/issues?limit=50&page=1' forgejo_page_url 'repos/o/r/issues?per_page=100' 1
|
||||||
|
|
||||||
|
# --- the forgejo gather: complete, or loudly refused --------------------
|
||||||
|
# curl is stubbed as a function so these are hermetic. Each case writes the
|
||||||
|
# headers and body a real Forgejo would.
|
||||||
|
|
||||||
|
# fake_forge <total-spec> <pages…> — install a curl stub serving <pages> as
|
||||||
|
# successive page bodies, declaring <total-spec> in x-total-count. An empty
|
||||||
|
# string omits the header entirely (@kimi's #4699 case). A comma-separated
|
||||||
|
# spec declares a DIFFERENT total per page ("4,9"), which is
|
||||||
|
# @codex-reviewer-andresmgsl's changing-between-pages case (#4700 / #4712):
|
||||||
|
# a server whose count moves under the walk cannot have been read whole.
|
||||||
|
fake_forge() {
|
||||||
|
FAKE_TOTAL="$1"; shift
|
||||||
|
FAKE_PAGES=("$@")
|
||||||
|
FAKE_CALLS=0
|
||||||
|
# shellcheck disable=SC2317 # the stub is invoked indirectly, by forge_api
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) hdr="$2"; shift ;;
|
||||||
|
-o) out="$2"; shift ;;
|
||||||
|
-H) shift ;;
|
||||||
|
-*) ;;
|
||||||
|
*) url="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
local page=1
|
||||||
|
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
|
||||||
|
local total="$FAKE_TOTAL"
|
||||||
|
case "$FAKE_TOTAL" in
|
||||||
|
*,*)
|
||||||
|
total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f"$page")"
|
||||||
|
[ -n "$total" ] || total="$(printf '%s' "$FAKE_TOTAL" | cut -d, -f1)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
{
|
||||||
|
printf 'HTTP/1.1 200 OK\r\n'
|
||||||
|
[ -n "$total" ] && printf 'X-Total-Count: %s\r\n' "$total"
|
||||||
|
printf '\r\n'
|
||||||
|
} >"$hdr"
|
||||||
|
if [ "$page" -le "${#FAKE_PAGES[@]}" ]; then
|
||||||
|
printf '%s' "${FAKE_PAGES[$((page - 1))]}" >"$out"
|
||||||
|
else
|
||||||
|
printf '[]' >"$out"
|
||||||
|
fi
|
||||||
|
FAKE_CALLS=$((FAKE_CALLS + 1))
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export CEREMONY_FORGE_API=https://forge.example/api/v1
|
||||||
|
|
||||||
|
# One page, and the count agrees with the declared total.
|
||||||
|
fake_forge 2 '[{"number":1},{"number":2}]'
|
||||||
|
check "a complete single-page gather returns its items" 0 "" \
|
||||||
|
eq $'1\n2' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# Two pages that add up. The walk must not stop at the first page merely
|
||||||
|
# because it came back non-empty — rig has 137 issues across 3 pages, which
|
||||||
|
# is the case this models.
|
||||||
|
fake_forge 4 '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
|
||||||
|
check "a multi-page gather walks every page" 0 "" \
|
||||||
|
eq $'1\n2\n3\n4' forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# The whole reason the assert exists: a server that declares more than it
|
||||||
|
# hands over must not produce a "successful" partial sweep.
|
||||||
|
fake_forge 137 '[{"number":1},{"number":2}]'
|
||||||
|
check "a short gather is refused, not reconciled" 1 "incomplete gather" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
check "...and the refusal names both counts" 1 "collected 2 of 137" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# @kimi-reviewer-andresmgsl's hardening (#4699): the guard must not be able
|
||||||
|
# to degrade silently either. A Forgejo that does not expose x-total-count
|
||||||
|
# leaves the assert with nothing to compare, and an assert that cannot run
|
||||||
|
# must refuse rather than pass.
|
||||||
|
fake_forge '' '[{"number":1},{"number":2}]'
|
||||||
|
check "a missing x-total-count refuses" 1 "did not send x-total-count" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
check "...and says why it cannot prove completeness" 1 "cannot prove the gather is complete" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# @codex-reviewer-andresmgsl's #4712 findings. Each one is a route by which
|
||||||
|
# an unprovable read could still have been reported as a whole one — the
|
||||||
|
# guard leaking the failure class it was built to stop, which is why they
|
||||||
|
# are refusals rather than warnings.
|
||||||
|
|
||||||
|
# A total that is not a number went straight into arithmetic. Reproduced on
|
||||||
|
# ab23a3b: `X-Total-Count: not-a-number` returned rc=0 with that string as
|
||||||
|
# the total.
|
||||||
|
fake_forge 'not-a-number' '[{"number":1}]'
|
||||||
|
check "a non-numeric total is refused" 1 "not a non-negative integer" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
check "...and the refusal quotes what arrived" 1 "not-a-number" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
fake_forge '12x' '[{"number":1}]'
|
||||||
|
check "a partly-numeric total is refused" 1 "not a non-negative integer" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
fake_forge '-3' '[{"number":1}]'
|
||||||
|
check "a negative total is refused" 1 "not a non-negative integer" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# A total that MOVES under the walk. The loop read it once, so a board
|
||||||
|
# changing size mid-gather was invisible: page 1 said 4, page 2 said 9, and
|
||||||
|
# the walk stopped at 4 believing itself complete.
|
||||||
|
fake_forge '4,9' '[{"number":1},{"number":2}]' '[{"number":3},{"number":4}]'
|
||||||
|
check "a total that changes between pages is refused" 1 "changed between pages" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
# The distinguishing text, not a substring that survives losing half the
|
||||||
|
# message: "4" alone stayed green if the later total vanished, which is what
|
||||||
|
# @codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734)
|
||||||
|
# both caught. A test named "names BOTH totals" must fail when one goes.
|
||||||
|
check "...and the refusal names both totals" 1 "4 then 9" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# A 200 whose body is not a collection. `length` on a non-array counted 0,
|
||||||
|
# so an object or a scalar arriving where a list belongs read as a complete
|
||||||
|
# EMPTY collection when the declared total was 0 — silence dressed as a
|
||||||
|
# clean sweep.
|
||||||
|
fake_forge 0 '{"message":"Not found"}'
|
||||||
|
check "a non-array body is refused" 1 "did not return a collection" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
fake_forge 0 '"a string"'
|
||||||
|
check "a scalar body is refused" 1 "did not return a collection" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
# A genuinely empty collection is still fine — the refusal must not fire on
|
||||||
|
# a repo that legitimately has nothing.
|
||||||
|
fake_forge 0 '[]'
|
||||||
|
check "an empty collection is not an error" 0 "" \
|
||||||
|
forge_api --paginate 'repos/o/r/issues' --jq '.[].number'
|
||||||
|
|
||||||
|
# --- HTTP failures are named, not swallowed -----------------------------
|
||||||
|
# gh exits non-zero on an HTTP error; curl does not without -f, and -f
|
||||||
|
# discards the body that explains why. So the status is read explicitly.
|
||||||
|
fake_forge 1 '[{"number":1}]'
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
||||||
|
curl() {
|
||||||
|
local hdr="" out=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf 'HTTP/1.1 404 Not Found\r\n\r\n' >"$hdr"
|
||||||
|
printf '{"message":"Not found"}' >"$out"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
check "a 404 is a named failure" 1 "HTTP 404" forge_api 'repos/o/r/issues/9999'
|
||||||
|
check "a 404 names the endpoint" 1 "repos/o/r/issues/9999" forge_api 'repos/o/r/issues/9999'
|
||||||
|
|
||||||
|
# --- forge_issue_edit: a typo must not become a green no-op --------------
|
||||||
|
# @codex-reviewer-andresmgsl (#4743). The github backend hands whatever it is
|
||||||
|
# given to `gh`, which fails on a flag it does not know. Dropping it here
|
||||||
|
# instead turned a mis-typed port site into a mutation that silently did not
|
||||||
|
# happen — this issue's own failure class, arriving inside the fix for it.
|
||||||
|
|
||||||
|
check "an unknown edit flag refuses" 1 "unknown flag" forge_issue_edit 1 --typo value
|
||||||
|
check "...and names the flag it refused" 1 "--typo" forge_issue_edit 1 --typo value
|
||||||
|
check "a flag with no value refuses" 1 "requires a value" forge_issue_edit 1 --add-label
|
||||||
|
|
||||||
|
# --- forge_label_create: an upsert, like gh's --force --------------------
|
||||||
|
# bootstrap_labels creates every declared label on EVERY workflow_dispatch,
|
||||||
|
# so a plain POST onto an existing name aborts the bootstrap under set -e
|
||||||
|
# from the second dispatch onward (#4743).
|
||||||
|
|
||||||
|
WRITES="$TMP/writes"
|
||||||
|
stub_writes() {
|
||||||
|
: >"$WRITES"
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" method=GET url="" payload=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) hdr="$2"; shift ;;
|
||||||
|
-o) out="$2"; shift ;;
|
||||||
|
-X) method="$2"; shift ;;
|
||||||
|
-d) payload="$2"; shift ;;
|
||||||
|
-H) shift ;;
|
||||||
|
-*) ;;
|
||||||
|
*) url="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_LABEL_N:-1}" >"$hdr"
|
||||||
|
case "$url" in
|
||||||
|
*"/labels?"* | */labels) printf '%s' "${FAKE_LABELS:-[]}" >"$out" ;;
|
||||||
|
*) printf '{}' >"$out" ;;
|
||||||
|
esac
|
||||||
|
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The label does not exist yet -> POST (create).
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_label_create ready 0e8a16 'in the queue'
|
||||||
|
check "creating a new label POSTs" 0 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
|
||||||
|
|
||||||
|
# The label already exists -> PATCH (update), which is what --force does.
|
||||||
|
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 stub_writes
|
||||||
|
FAKE_LABELS='[{"name":"ready","id":7}]' FAKE_LABEL_N=1 REPO=o/r forge_label_create ready 0e8a16 'new text'
|
||||||
|
check "recreating an existing label PATCHes it" 0 "" \
|
||||||
|
grep -q '^PATCH repos/o/r/labels/7 ' "$WRITES"
|
||||||
|
check "...and does not POST a duplicate" 1 "" grep -q '^POST repos/o/r/labels ' "$WRITES"
|
||||||
|
check "...carrying the updated description" 0 "" grep -q 'new text' "$WRITES"
|
||||||
|
|
||||||
|
# --- forge_issue_edit on forgejo: the two asymmetries, hermetically ------
|
||||||
|
# Promised with the call-site port (@grok-reviewer-andresmgsl #4741 note 2,
|
||||||
|
# #4751 item 2). Live scratch-repo evidence proved these work; these prove
|
||||||
|
# they keep working, and pin the SHAPE of the requests.
|
||||||
|
|
||||||
|
# Removal resolves name -> id, because Forgejo takes names on add and only a
|
||||||
|
# numeric id on remove. Measured: DELETE .../labels/probe:one -> 422,
|
||||||
|
# DELETE .../labels/149 -> 204.
|
||||||
|
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 stub_writes
|
||||||
|
FAKE_LABELS='[{"name":"stale","id":11},{"name":"ready","id":12}]' FAKE_LABEL_N=2 REPO=o/r forge_issue_edit 5 --remove-label stale
|
||||||
|
check "removing a label resolves its numeric id" 0 "" grep -q '^DELETE repos/o/r/issues/5/labels/11 ' "$WRITES"
|
||||||
|
check "...and never sends the name as the path segment" 1 "" grep -q 'labels/stale' "$WRITES"
|
||||||
|
|
||||||
|
# A label the repo does not have is a no-op, matching gh: the reconcilers
|
||||||
|
# call --remove-label unconditionally to converge state.
|
||||||
|
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 stub_writes
|
||||||
|
FAKE_LABELS='[{"name":"ready","id":12}]' FAKE_LABEL_N=1 REPO=o/r forge_issue_edit 5 --remove-label nonexistent
|
||||||
|
check "removing an absent label writes nothing" 0 "" test ! -s "$WRITES"
|
||||||
|
|
||||||
|
# Adding takes names directly — no lookup, one request.
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_issue_edit 5 --add-label "ready,stale"
|
||||||
|
check "adding labels posts them by name" 0 "" grep -q '^POST repos/o/r/issues/5/labels .*"ready"' "$WRITES"
|
||||||
|
check "...comma-separated values are split, as gh splits them" 0 "" grep -q '"stale"' "$WRITES"
|
||||||
|
|
||||||
|
# Assignees are SET, not added/removed: PATCH takes the whole list. So a
|
||||||
|
# removal is a read-modify-write, and a naive translation would have cleared
|
||||||
|
# every OTHER assignee as a side effect of removing one.
|
||||||
|
assignee_stub() {
|
||||||
|
: >"$WRITES"
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_issue_edit
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" method=GET url="" payload=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) hdr="$2"; shift ;; -o) out="$2"; shift ;;
|
||||||
|
-X) method="$2"; shift ;; -d) payload="$2"; shift ;;
|
||||||
|
-H) shift ;; -*) ;; *) url="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 0\r\n\r\n' >"$hdr"
|
||||||
|
printf '{"assignees":[{"login":"alice"},{"login":"bob"}]}' >"$out"
|
||||||
|
[ "$method" = GET ] || printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assignee_stub
|
||||||
|
REPO=o/r forge_issue_edit 5 --remove-assignee alice
|
||||||
|
check "removing one assignee PATCHes the surviving list" 0 "" grep -q '^PATCH repos/o/r/issues/5 .*"bob"' "$WRITES"
|
||||||
|
check "...and the removed one is gone from it" 1 "" grep -q '"alice"' "$WRITES"
|
||||||
|
|
||||||
|
assignee_stub
|
||||||
|
REPO=o/r forge_issue_edit 5 --add-assignee carol
|
||||||
|
check "adding an assignee keeps the existing ones" 0 "" grep -qE '^PATCH repos/o/r/issues/5 .*"alice".*"bob".*"carol"|^PATCH repos/o/r/issues/5 .*"alice".*"carol".*"bob"' "$WRITES"
|
||||||
|
|
||||||
|
# --- forge_labels_add / forge_request_reviewer, both backends ------------
|
||||||
|
# @codex-reviewer-andresmgsl #4780 item 3. These two writes came in with the
|
||||||
|
# call-site port and had no boundary pins of their own.
|
||||||
|
|
||||||
|
# ceremony#128 is the whole reason forge_labels_add exists as its own verb.
|
||||||
|
# The labeler action computed (labels-at-job-start union derived) and PUT the
|
||||||
|
# whole set, so a label applied while the job ran was silently removed —
|
||||||
|
# ceremony#128 lost its `release` label, the merge door's declared-intent
|
||||||
|
# read, two seconds after the builder set it. This write must therefore be an
|
||||||
|
# ADDITIVE POST and must never read-modify-write.
|
||||||
|
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 stub_writes
|
||||||
|
FAKE_LABELS='[{"name":"scope:docs","id":21}]' FAKE_LABEL_N=1 \
|
||||||
|
REPO=o/r forge_labels_add 7 scope:docs scope:cli
|
||||||
|
check "labels_add POSTs to the issue labels collection" 0 "" \
|
||||||
|
grep -q '^POST repos/o/r/issues/7/labels ' "$WRITES"
|
||||||
|
check "...carrying every name in one request" 0 "" \
|
||||||
|
grep -q '"scope:docs","scope:cli"' "$WRITES"
|
||||||
|
# The regression that would reopen ceremony#128: any PUT, or a GET-then-write.
|
||||||
|
check "...and never PUTs the whole set (ceremony#128)" 1 "" grep -q '^PUT ' "$WRITES"
|
||||||
|
check "...exactly one write, so nothing is read-modify-written" 0 "" \
|
||||||
|
test "$(wc -l <"$WRITES")" -eq 1
|
||||||
|
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_labels_add 7
|
||||||
|
check "labels_add with no labels writes nothing" 0 "" test ! -s "$WRITES"
|
||||||
|
|
||||||
|
# The reviewer payload shape. Measured against this instance: the endpoint
|
||||||
|
# serves post and delete only, and takes {"reviewers":[...]}.
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 stub_writes
|
||||||
|
FAKE_LABELS='[]' FAKE_LABEL_N=0 REPO=o/r forge_request_reviewer 9 danmt
|
||||||
|
check "request_reviewer POSTs to requested_reviewers" 0 "" \
|
||||||
|
grep -q '^POST repos/o/r/pulls/9/requested_reviewers ' "$WRITES"
|
||||||
|
check "...with the reviewers array payload" 0 "" \
|
||||||
|
grep -q '{"reviewers":\["danmt"\]}' "$WRITES"
|
||||||
|
|
||||||
|
# The github twin is a 1:1 gh pass-through (term 5), so its parity is pinned
|
||||||
|
# by the command it builds rather than by an HTTP shape.
|
||||||
|
gh_calls="$TMP/ghcalls"
|
||||||
|
: >"$gh_calls"
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
|
||||||
|
gh() { printf '%s\n' "$*" >>"$gh_calls"; }
|
||||||
|
# A subshell so the github backend does not stay loaded over the forgejo
|
||||||
|
# cases below; REPO is deliberately scoped to it for the same reason.
|
||||||
|
(
|
||||||
|
forge_select github
|
||||||
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
||||||
|
REPO=o/r
|
||||||
|
forge_labels_add 7 scope:docs scope:cli
|
||||||
|
forge_request_reviewer 9 danmt
|
||||||
|
)
|
||||||
|
check "github labels_add uses the additive api POST, not issue edit" 0 "" \
|
||||||
|
grep -q 'api repos/o/r/issues/7/labels -f labels\[\]=scope:docs -f labels\[\]=scope:cli' "$gh_calls"
|
||||||
|
check "...and never routes through issue edit --add-label" 1 "" \
|
||||||
|
grep -q 'issue edit' "$gh_calls"
|
||||||
|
check "github request_reviewer posts the reviewer" 0 "" \
|
||||||
|
grep -q 'api repos/o/r/pulls/9/requested_reviewers -f reviewers\[\]=danmt' "$gh_calls"
|
||||||
|
|
||||||
|
# --- term-5 pins for the batch verbs (codex 1566) -------------------------
|
||||||
|
# The forgejo twins have hermetic coverage below; these pin that the github
|
||||||
|
# twins stay 1:1 extractions of the pre-port endpoints, not silent rewrites.
|
||||||
|
: >"$gh_calls"
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by the github verbs
|
||||||
|
gh() {
|
||||||
|
printf '%s\n' "$*" >>"$gh_calls"
|
||||||
|
if [ "$1" = api ]; then
|
||||||
|
shift
|
||||||
|
local jqexpr="" endpoint=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--jq) jqexpr="$2"; shift ;;
|
||||||
|
--paginate) ;;
|
||||||
|
-*) ;;
|
||||||
|
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
local body='[]'
|
||||||
|
case "$endpoint" in
|
||||||
|
*'/issues/'*'/timeline'*)
|
||||||
|
body='[{"event":"labeled","actor":{"login":"alice"},"label":{"name":"ready"},"created_at":"2026-08-01T09:00:00Z"}]'
|
||||||
|
;;
|
||||||
|
*'/issues/'*'/comments'*)
|
||||||
|
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
|
||||||
|
;;
|
||||||
|
*'/pulls/'*'/comments'*)
|
||||||
|
body='[{"created_at":"2026-08-01T10:30:00Z"}]'
|
||||||
|
;;
|
||||||
|
*'/pulls/'*'/commits'*)
|
||||||
|
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" <<<"$body"; else printf '%s\n' "$body"; fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
gh_tl="$(
|
||||||
|
forge_select github
|
||||||
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
||||||
|
REPO=o/r
|
||||||
|
forge_timeline 42
|
||||||
|
)"
|
||||||
|
check "github forge_timeline paginates the issue timeline endpoint" 0 "" \
|
||||||
|
grep -qE 'api --paginate repos/o/r/issues/42/timeline|api repos/o/r/issues/42/timeline' "$gh_calls"
|
||||||
|
check "github forge_timeline is a pass-through of the GitHub event shape" 0 "" \
|
||||||
|
jq -e '.[0].event == "labeled" and .[0].actor.login == "alice"' <<<"$gh_tl" >/dev/null
|
||||||
|
# The NEGATIVE half of "pass-through". A positive pin still passes if the
|
||||||
|
# github path GAINS forgejo behaviour, and term 5 is a statement about what
|
||||||
|
# must NOT change — so the drift these two catch is the only kind a
|
||||||
|
# 1:1-extraction path realistically suffers.
|
||||||
|
#
|
||||||
|
# The GitHub timeline already IS the shape ruling.sh selects on, so a
|
||||||
|
# projection here would be a second, divergent normalizer maintained by
|
||||||
|
# nobody.
|
||||||
|
check "...and never reshapes it, as the forgejo backend must" 1 "" \
|
||||||
|
grep -q 'issues/42/timeline.*--jq' "$gh_calls"
|
||||||
|
: >"$gh_calls"
|
||||||
|
gh_act="$(
|
||||||
|
forge_select github
|
||||||
|
# shellcheck disable=SC2030 # scoping REPO to this subshell is the point
|
||||||
|
REPO=o/r
|
||||||
|
forge_pr_activity 9 | sort
|
||||||
|
)"
|
||||||
|
check "github forge_pr_activity hits issue comments" 0 "" \
|
||||||
|
grep -q 'repos/o/r/issues/9/comments' "$gh_calls"
|
||||||
|
check "github forge_pr_activity hits the flat /pulls/{n}/comments endpoint" 0 "" \
|
||||||
|
grep -q 'repos/o/r/pulls/9/comments' "$gh_calls"
|
||||||
|
check "github forge_pr_activity hits commits" 0 "" \
|
||||||
|
grep -q 'repos/o/r/pulls/9/commits' "$gh_calls"
|
||||||
|
check "github forge_pr_activity emits all three timestamp sources" 0 "" \
|
||||||
|
test "$(printf '%s\n' "$gh_act")" = "$(printf '%s\n' '2026-08-01T10:00:00Z' '2026-08-01T10:30:00Z' '2026-08-01T11:00:00Z')"
|
||||||
|
# The forgejo backend derives inline comments from reviews with
|
||||||
|
# comments_count > 0, because the flat endpoint 404s there. That derivation
|
||||||
|
# must never appear on this path: GitHub serves the flat endpoint, and a
|
||||||
|
# github twin quietly adopting the workaround is the exact "both backends
|
||||||
|
# drift together" failure term 5 forbids.
|
||||||
|
check "...never deriving them from reviews, as forgejo must" 1 "" \
|
||||||
|
grep -q 'pulls/[0-9]*/reviews' "$gh_calls"
|
||||||
|
|
||||||
|
unset -f gh
|
||||||
|
. "$ROOT/lib/forge-forgejo.sh"
|
||||||
|
|
||||||
|
# --- forge_pr_view: newest verdict per context must win ------------------
|
||||||
|
# checks_state groups repeated contexts and selects the newest by
|
||||||
|
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
|
||||||
|
# the winner to incidental array order, so a stale re-run could outrank the
|
||||||
|
# live one (#4743). Forgejo's combined status carries created_at/updated_at
|
||||||
|
# — measured on this instance, where Actions DO land as commit statuses
|
||||||
|
# (rig main: "ci / check (push)" success, with created_at).
|
||||||
|
pr_view_stub() {
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_pr_view
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
|
||||||
|
case "$url" in
|
||||||
|
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;;
|
||||||
|
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# The FAILURE is older but listed second — array order would pick it.
|
||||||
|
FAKE_STATUS='{"state":"failure","statuses":[
|
||||||
|
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
|
||||||
|
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}'
|
||||||
|
pr_view_stub
|
||||||
|
view_json="$(REPO=o/r forge_pr_view 5)"
|
||||||
|
check "pr_view maps createdAt" 0 "" \
|
||||||
|
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
|
||||||
|
check "pr_view maps completedAt" 0 "" \
|
||||||
|
grep -q '"completedAt":' <<<"$view_json"
|
||||||
|
check "pr_view maps mergeable to the UI string" 0 "" \
|
||||||
|
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
|
||||||
|
# The real proof: feed it to the production classifier and confirm the newer
|
||||||
|
# SUCCESS wins over the older FAILURE regardless of array order.
|
||||||
|
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
||||||
|
. "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
|
||||||
|
classified="$(checks_state <<<"$view_json")"
|
||||||
|
check "the newest verdict per context wins, not the array order" 0 "" \
|
||||||
|
test "$classified" = SUCCESS
|
||||||
|
|
||||||
|
# --- the api base must be known -----------------------------------------
|
||||||
|
check "no api base refuses" 1 "cannot reach the forge" \
|
||||||
|
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
|
||||||
|
|
||||||
|
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
|
||||||
|
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
|
||||||
|
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
|
||||||
|
# emitting .user instead of .actor each reds its own case (#4853).
|
||||||
|
timeline_stub() {
|
||||||
|
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
|
||||||
|
case "$url" in
|
||||||
|
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
|
||||||
|
*) printf '[]' >"$out" ;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FAKE_TIMELINE='[
|
||||||
|
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
|
||||||
|
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
|
||||||
|
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
|
||||||
|
]'
|
||||||
|
FAKE_TL_N=3
|
||||||
|
timeline_stub
|
||||||
|
tl="$(REPO=o/r forge_timeline 188)"
|
||||||
|
check "forge_timeline projects body=1 to labeled" 0 "" \
|
||||||
|
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
|
||||||
|
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
|
||||||
|
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
|
||||||
|
check "forge_timeline drops non-label events" 0 "" \
|
||||||
|
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
|
||||||
|
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
|
||||||
|
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
|
||||||
|
# Unreadable: curl fails. Status must surface through forge_timeline itself
|
||||||
|
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
|
||||||
|
# shellcheck disable=SC2317
|
||||||
|
curl() { return 22; }
|
||||||
|
tl_unreadable() { REPO=o/r forge_timeline 188; }
|
||||||
|
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
|
||||||
|
|
||||||
|
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
|
||||||
|
# Only reviews with comments_count > 0 are fetched (#4844).
|
||||||
|
activity_calls="$TMP/activity_calls"
|
||||||
|
: >"$activity_calls"
|
||||||
|
activity_stub() {
|
||||||
|
# shellcheck disable=SC2317
|
||||||
|
curl() {
|
||||||
|
local hdr="" out="" url="" total=1 body='[]'
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
printf '%s\n' "$url" >>"$activity_calls"
|
||||||
|
case "$url" in
|
||||||
|
*'/issues/'*'/comments'*)
|
||||||
|
total=1
|
||||||
|
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
|
||||||
|
;;
|
||||||
|
*'/pulls/'*'/commits'*)
|
||||||
|
total=1
|
||||||
|
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
|
||||||
|
;;
|
||||||
|
*'/reviews/'*'/comments'*)
|
||||||
|
total=1
|
||||||
|
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
|
||||||
|
;;
|
||||||
|
*'/pulls/'*'/reviews'*)
|
||||||
|
total=2
|
||||||
|
body="$FAKE_REVIEWS"
|
||||||
|
;;
|
||||||
|
*) total=0; body='[]' ;;
|
||||||
|
esac
|
||||||
|
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
|
||||||
|
printf '%s' "$body" >"$out"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
|
||||||
|
activity_stub
|
||||||
|
: >"$activity_calls"
|
||||||
|
act="$(REPO=o/r forge_pr_activity 9 | sort)"
|
||||||
|
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
|
||||||
|
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
|
||||||
|
check "forge_pr_activity emits commit timestamps" 0 "" \
|
||||||
|
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
|
||||||
|
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
|
||||||
|
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
|
||||||
|
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
|
||||||
|
grep -q '/reviews/7/comments' "$activity_calls"
|
||||||
|
check "...and never fetches a zero-comment review" 1 "" \
|
||||||
|
grep -q '/reviews/8/comments' "$activity_calls"
|
||||||
|
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
|
||||||
|
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
|
||||||
|
|
||||||
|
summary
|
||||||
175
test/forge.test.sh
Normal file
175
test/forge.test.sh
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
|
||||||
|
# commands are behavior for the harness to inspect.
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TMP="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
|
||||||
|
# NO forge clients on it — that is the condition under test. It cannot be a
|
||||||
|
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
|
||||||
|
# and the heredoc refusals use `cat`.
|
||||||
|
mkdir -p "$TMP/empty"
|
||||||
|
for _t in bash cat sed awk tr printf; do
|
||||||
|
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
|
||||||
|
done
|
||||||
|
# shellcheck source=test/harness.sh
|
||||||
|
. "$ROOT/test/harness.sh"
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
|
||||||
|
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
|
||||||
|
# substring match cannot prove "forgejo" was not printed as "forgejox".
|
||||||
|
eq() {
|
||||||
|
local want="$1" got
|
||||||
|
shift
|
||||||
|
got="$("$@")" || return 1
|
||||||
|
[ "$got" = "$want" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# detect_in <env-assignments…> — run forge_detect in a clean environment
|
||||||
|
# carrying only the named vars, so a leaked GITHUB_* from the CI running
|
||||||
|
# THIS suite cannot decide the answer. Every case below is hermetic.
|
||||||
|
detect_in() {
|
||||||
|
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
|
||||||
|
}
|
||||||
|
|
||||||
|
# A PATH carrying stub binaries for every client the preflight can require.
|
||||||
|
# Without this the "passes" cases depend on whatever the HOST happens to have,
|
||||||
|
# which is not hermetic and is wrong in the only place it matters: the Forgejo
|
||||||
|
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
|
||||||
|
# "github + gh passes" failed there while passing on a developer box. Measured
|
||||||
|
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
|
||||||
|
# exists to find.
|
||||||
|
STUBBIN="$TMP/bin"
|
||||||
|
mkdir -p "$STUBBIN"
|
||||||
|
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
|
||||||
|
|
||||||
|
preflight_in() {
|
||||||
|
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ...and one with NO clients at all, for the missing-binary refusal.
|
||||||
|
preflight_bare() {
|
||||||
|
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- forge_detect: the explicit override --------------------------------
|
||||||
|
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
|
||||||
|
# whose env this file has not met yet, and the handle the tests below use
|
||||||
|
# to drive the backends without a live instance.
|
||||||
|
|
||||||
|
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
|
||||||
|
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
|
||||||
|
check "override refuses an unknown forge" 1 "unknown forge" \
|
||||||
|
detect_in CEREMONY_FORGE=gitlab
|
||||||
|
# A typo must not silently fall through to a probe that guesses right by
|
||||||
|
# accident: the operator said something, and it was wrong.
|
||||||
|
check "override outranks the env" 1 "unknown forge" \
|
||||||
|
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
|
||||||
|
|
||||||
|
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
|
||||||
|
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
|
||||||
|
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
|
||||||
|
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
|
||||||
|
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
|
||||||
|
# those URLs point:
|
||||||
|
#
|
||||||
|
# GitHub GITHUB_API_URL=https://api.github.com
|
||||||
|
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
||||||
|
#
|
||||||
|
# That is the whole bug this issue exists for, in one variable: gh speaks
|
||||||
|
# /api/v3 against api.github.com, and neither half is true here.
|
||||||
|
|
||||||
|
check "api url: api.github.com is github" 0 "" \
|
||||||
|
eq github detect_in GITHUB_API_URL=https://api.github.com
|
||||||
|
check "api url: /api/v1 is forgejo" 0 "" \
|
||||||
|
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
|
||||||
|
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
|
||||||
|
# it is a github backend on a non-github.com host. Getting this wrong would
|
||||||
|
# route a GHES consumer to the forgejo backend and break term 5.
|
||||||
|
check "api url: GHES /api/v3 is github" 0 "" \
|
||||||
|
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
|
||||||
|
|
||||||
|
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
|
||||||
|
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
|
||||||
|
# which GitHub never sets. It is checked BEFORE the URL shape because it is
|
||||||
|
# unambiguous where a hand-set GITHUB_API_URL might not be.
|
||||||
|
|
||||||
|
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
|
||||||
|
check "gitea marker outranks a github-shaped api url" 0 "" \
|
||||||
|
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
|
||||||
|
|
||||||
|
# --- forge_detect: refusing to guess ------------------------------------
|
||||||
|
# Nothing to read is NOT "probably github". A wrong guess here is exactly
|
||||||
|
# the silent blind sweep #188 measured; the whole point of this file is
|
||||||
|
# that an unknown forge is loud.
|
||||||
|
|
||||||
|
check "bare environment refuses" 1 "cannot determine which forge" detect_in
|
||||||
|
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
|
||||||
|
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
|
||||||
|
|
||||||
|
# --- forge_preflight: the must-fail case --------------------------------
|
||||||
|
# The Test plan's named must-fail: "point it at a Forgejo instance with a
|
||||||
|
# GitHub-shaped client and assert it refuses loudly rather than sweeping
|
||||||
|
# blind."
|
||||||
|
#
|
||||||
|
# Measured before this guard existed, against this instance:
|
||||||
|
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
|
||||||
|
# labels-reconcile exit 0 "reconciled." (zero PRs read)
|
||||||
|
# issueflow-reconcile exit 1 "unexpected end of JSON input"
|
||||||
|
# Two of three swept blind and reported success. gh present made it WORSE:
|
||||||
|
# it silenced the one loud failure. Hence: refuse before the sweep, not
|
||||||
|
# after — and say which forge and which client, so the log answers "why"
|
||||||
|
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
|
||||||
|
|
||||||
|
check "forgejo + gh-only client refuses" 1 "cannot speak" \
|
||||||
|
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
||||||
|
check "the refusal names the forge" 1 "forgejo" \
|
||||||
|
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
||||||
|
# The interpolated client, not the bare string "gh" — which also appears in
|
||||||
|
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
|
||||||
|
# stayed green even if the client name never reached the message. Same class
|
||||||
|
# as the "names both totals" weakness the panel caught in the backend suite
|
||||||
|
# (#4727 / #4734); found by auditing this file for the same shape.
|
||||||
|
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
|
||||||
|
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
||||||
|
# The refusal must be actionable, not merely loud: #188's whole cost was a
|
||||||
|
# red check that told nobody what to do.
|
||||||
|
check "the refusal names the issue" 1 "#188" \
|
||||||
|
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
|
||||||
|
|
||||||
|
# --- forge_preflight: the passing pairs ---------------------------------
|
||||||
|
|
||||||
|
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
|
||||||
|
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
|
||||||
|
# The mirror of the must-fail: a Forgejo client against GitHub is just as
|
||||||
|
# wrong, and symmetric refusal is cheaper than explaining why only one
|
||||||
|
# direction is checked.
|
||||||
|
check "github + rest refuses" 1 "cannot speak" \
|
||||||
|
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
|
||||||
|
|
||||||
|
# --- forge_preflight: it refuses when the forge itself is unknown --------
|
||||||
|
# Detection failure must not be swallowed into a pass — that would restore
|
||||||
|
# the blind sweep through the back door.
|
||||||
|
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
|
||||||
|
|
||||||
|
# --- forge_client: what each backend actually needs ----------------------
|
||||||
|
# Measured in the runner image the Forgejo instance actually uses
|
||||||
|
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
|
||||||
|
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
|
||||||
|
# necessity, not preference — this is the measurement that retired option
|
||||||
|
# A (port to stoke) as well: the CLI is not on the runner either.
|
||||||
|
|
||||||
|
check "github backend wants gh" 0 "" eq gh forge_client github
|
||||||
|
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
|
||||||
|
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
|
||||||
|
|
||||||
|
# The missing-binary arm, hermetically: an empty PATH has no client at all.
|
||||||
|
check "a forge whose client is not installed refuses" 1 "is not installed" \
|
||||||
|
preflight_bare CEREMONY_FORGE=github
|
||||||
|
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
|
||||||
|
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
|
||||||
|
|
||||||
|
summary
|
||||||
|
|
@ -34,3 +34,13 @@ summary() {
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
|
||||||
|
# injects (#188) so a fixture keyed on the logical endpoint still matches.
|
||||||
|
# The page size moved OUT of the call sites and into the backend, which means
|
||||||
|
# every stub now sees "?per_page=100" appended to a paginated read; without
|
||||||
|
# this, a fixture lookup misses and the stub answers "unreadable", which the
|
||||||
|
# production code correctly reports as a degraded read.
|
||||||
|
forge_stub_path() {
|
||||||
|
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
source "$ROOT/test/harness.sh"
|
source "$ROOT/test/harness.sh"
|
||||||
|
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
||||||
|
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
||||||
|
# stubs already intercept (#188). Without this the verbs are undefined.
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
forge_select github
|
||||||
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
|
||||||
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
source "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
|
||||||
|
|
@ -182,7 +188,7 @@ chmod +x "$TMP/gh"
|
||||||
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
|
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
|
||||||
check "cross-repo warning is idempotent across two sweeps" 0 "" \
|
check "cross-repo warning is idempotent across two sweeps" 0 "" \
|
||||||
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
|
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
|
||||||
'source "$1"; REPO=heavy-duty/ceremony
|
'source "$1"; forge_select github; REPO=heavy-duty/ceremony
|
||||||
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
||||||
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
ensure_comment 99 blocked-cross-repo "cross-repo warning"
|
||||||
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
|
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
|
||||||
|
|
@ -234,6 +240,7 @@ check "claimed plus attention is a healthy issue" 0 "KEEP" \
|
||||||
INOW=2000000000
|
INOW=2000000000
|
||||||
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
iso_at() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
issue_stub_gh() {
|
issue_stub_gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -246,6 +253,7 @@ issue_stub_gh() {
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
endpoint="$(forge_stub_path "$endpoint")"
|
||||||
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
|
printf '%s\n' "$endpoint" >>"$TMP/api-calls"
|
||||||
[ ! -f "$file.error" ] || return 1
|
[ ! -f "$file.error" ] || return 1
|
||||||
|
|
@ -286,6 +294,7 @@ issue_probe() { # $1 issue, $2 labels, $3 assignees, $4 open PR, $5 merged PR, $
|
||||||
MERGED_REF_PR_RECORDS=""
|
MERGED_REF_PR_RECORDS=""
|
||||||
fi
|
fi
|
||||||
run() { "$@"; }
|
run() { "$@"; }
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() { issue_stub_gh "$@"; }
|
gh() { issue_stub_gh "$@"; }
|
||||||
reconcile_issue "$1" 2>&1
|
reconcile_issue "$1" 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -531,6 +540,7 @@ jq -n --arg flag "$(iso_at $((INOW - 8 * 86400)))" \
|
||||||
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
|
jq -n --arg at "$(iso_at $((INOW - 8 * 86400 - 60)))" \
|
||||||
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
|
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
|
||||||
>"$(cfix 24)"
|
>"$(cfix 24)"
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend (#188)
|
||||||
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
|
churn_last="$( (REPO=owner/repo; gh() { issue_stub_gh "$@"; }
|
||||||
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
|
last_issue_activity 24 "$(iso_at $((INOW - 10 * 86400)))") )"
|
||||||
check "last activity ignores the 2-day-old label churn" 0 "" \
|
check "last activity ignores the 2-day-old label churn" 0 "" \
|
||||||
|
|
@ -566,6 +576,11 @@ if [ "$1" = api ]; then
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
# Inlined, not the suite's helper: this stub is a standalone executable on
|
||||||
|
# PATH and cannot see a shell function from the test process. Strips the
|
||||||
|
# paging the forge shim injects so fixtures stay keyed on the logical
|
||||||
|
# endpoint (#188).
|
||||||
|
endpoint="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
||||||
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
|
file="$GH_FIXTURES/$(printf '%s' "$endpoint" | tr '/?&=' '____').json"
|
||||||
[ ! -f "$file.error" ] || exit 1
|
[ ! -f "$file.error" ] || exit 1
|
||||||
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi
|
if [ -f "$file" ]; then payload="$(cat "$file")"; else payload='[]'; fi
|
||||||
|
|
@ -577,13 +592,22 @@ echo "gh stub: unexpected call: gh $*" >&2
|
||||||
exit 97
|
exit 97
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$ARRIVAL/stub/gh"
|
chmod +x "$ARRIVAL/stub/gh"
|
||||||
printf '%s\n' \
|
# The two PR gathers were GraphQL until #188; they are REST now, so the
|
||||||
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
# fixtures are the /pulls list both forges return. Empty by default — the
|
||||||
>"$ARRIVAL/fixtures/graphql.json"
|
# merged-Refs case below fills the closed one.
|
||||||
|
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
|
||||||
|
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
||||||
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
|
arrival_fixture() { printf '%s\n' "$1" >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json"; }
|
||||||
|
# CEREMONY_FORGE=github below, and at the executable-sweep driver further
|
||||||
|
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering
|
||||||
|
# /api/v3 shapes), so the suite says so at the forge boundary rather than
|
||||||
|
# letting main()'s preflight infer a forge from whatever env the CI job
|
||||||
|
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
|
||||||
|
# boundary this issue moved.
|
||||||
arrival_run() {
|
arrival_run() {
|
||||||
: >"$ARRIVAL/fixtures/edits"
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
|
CEREMONY_FORGE=github \
|
||||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
|
EVENT_NAME=issues EVENT_ACTION=opened EVENT_ISSUE=91 \
|
||||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
|
||||||
|
|
@ -619,13 +643,17 @@ check "...and the sweep still runs" 0 "" \
|
||||||
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
grep -qF 'issueflow: reconciled.' <<<"$pr_out"
|
||||||
|
|
||||||
# The merged-Refs transition must survive the executable's set -e path too.
|
# The merged-Refs transition must survive the executable's set -e path too.
|
||||||
# Keep this at main() granularity: the GraphQL gather and loop are the code
|
# Keep this at main() granularity: the PR gather and loop are the code a
|
||||||
# a sourced decision probe cannot exercise (#91's lesson).
|
# sourced decision probe cannot exercise (#91's lesson).
|
||||||
|
#
|
||||||
|
# merged_at is what makes this PR merged rather than merely closed — the
|
||||||
|
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
|
||||||
|
# return the field, and both return null on a closed-unmerged PR.
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":400,"body":"Refs #40","closingIssuesReferences":{"nodes":[]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
|
'[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
|
||||||
>"$ARRIVAL/fixtures/graphql.json"
|
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
||||||
printf '[{"number":40}]\n' \
|
printf '[{"number":40}]\n' \
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open_per_page_100.json"
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
|
||||||
jq -n --arg at "$(iso_at "$INOW")" \
|
jq -n --arg at "$(iso_at "$INOW")" \
|
||||||
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
||||||
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_40.json"
|
||||||
|
|
@ -633,18 +661,95 @@ printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_40_comments.json"
|
||||||
: >"$ARRIVAL/fixtures/edits"
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
subprocess_out="$(
|
subprocess_out="$(
|
||||||
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
|
CEREMONY_FORGE=github \
|
||||||
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||||
)"
|
)"
|
||||||
subprocess_rc=$?
|
subprocess_rc=$?
|
||||||
check "executable sweep transitions merged Refs work" 0 "" \
|
check "executable sweep transitions merged Refs work" 0 "" \
|
||||||
test "$subprocess_rc" -eq 0
|
test "$subprocess_rc" -eq 0
|
||||||
check "...reaches the transition through GraphQL and the issue loop" 0 "" \
|
check "...reaches the transition through the REST gather and the issue loop" 0 "" \
|
||||||
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<<"$subprocess_out"
|
||||||
check "...and performs the release edit from the executable path" 0 "" \
|
check "...and performs the release edit from the executable path" 0 "" \
|
||||||
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
|
||||||
"$ARRIVAL/fixtures/edits"
|
"$ARRIVAL/fixtures/edits"
|
||||||
|
|
||||||
|
# -- the issue/PR discriminator is not `has("pull_request")` ---------------
|
||||||
|
# Measured on the two list endpoints, 2026-08-02 (#188):
|
||||||
|
#
|
||||||
|
# GitHub plain issues OMIT the key -> 0 of 9 carried it
|
||||||
|
# Forgejo every entry HAS the key -> 10 of 10, valued null on issues
|
||||||
|
#
|
||||||
|
# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and
|
||||||
|
# the sweep printed "reconciled." over an untouched board — the blind sweep
|
||||||
|
# again, one layer in, and invisible because the log looks identical to a
|
||||||
|
# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live
|
||||||
|
# board, not by any unit test. `.pull_request == null` is true for an issue on
|
||||||
|
# both forges (an absent key reads as null) and false for a PR on both.
|
||||||
|
disc() { jq -e "$1" >/dev/null 2>&1 && echo issue || echo pr; }
|
||||||
|
check "github-shaped issue (key absent) reads as an issue" 0 "issue" \
|
||||||
|
bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
|
||||||
|
check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \
|
||||||
|
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
|
||||||
|
check "a PR reads as a PR on either shape" 0 "pr" \
|
||||||
|
bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
|
||||||
|
# The old test, kept as the must-fail: it disagrees with the new one on the
|
||||||
|
# forgejo shape, which is exactly the bug.
|
||||||
|
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
|
||||||
|
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
|
||||||
|
|
||||||
|
# -- the OPEN-pull gather, at main() granularity ----------------------------
|
||||||
|
# The closed/merged half above proves one REST path; this proves the other,
|
||||||
|
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
|
||||||
|
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
|
||||||
|
# test/closes_references.test.sh cannot reach it — they test the parser, not
|
||||||
|
# the encoding and wiring around it (#188).
|
||||||
|
#
|
||||||
|
# Both directions in ONE sweep, so neither assertion can pass vacuously:
|
||||||
|
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
|
||||||
|
# #51 is closed by nothing -> the claim is RECLAIMED
|
||||||
|
# A break anywhere in the pipeline reclaims #50 too, and the first check
|
||||||
|
# fails. A break that reclaims nothing fails the second.
|
||||||
|
#
|
||||||
|
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
|
||||||
|
# escapes a newline to a literal backslash-n, so a line-oriented parser
|
||||||
|
# reading an @tsv-encoded body sees one line and drops everything after the
|
||||||
|
# first — with the declaration on line 3, that defect reclaims #50 and this
|
||||||
|
# case goes red. On line 1 it would pass either way, which is the definition
|
||||||
|
# of a vacuous test.
|
||||||
|
printf '%s\n' \
|
||||||
|
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \
|
||||||
|
>"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_open.json"
|
||||||
|
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_pulls_state_closed.json"
|
||||||
|
printf '[{"number":50},{"number":51}]\n' \
|
||||||
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_state_open.json"
|
||||||
|
# Both claims are two hours quiet against a ONE-hour stale bound, so the
|
||||||
|
# reclaim clock has genuinely expired for whichever of them no open PR
|
||||||
|
# rescues. The clock is injected rather than real: INOW is a fixed epoch in
|
||||||
|
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
|
||||||
|
# dates these claims in the future, and both survive on a negative age —
|
||||||
|
# which is a green test proving nothing.
|
||||||
|
for n in 50 51; do
|
||||||
|
jq -n --arg at "$(iso_at $((INOW - 7200)))" --argjson n "$n" \
|
||||||
|
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
|
||||||
|
>"$ARRIVAL/fixtures/repos_owner_repo_issues_$n.json"
|
||||||
|
printf '[]\n' >"$ARRIVAL/fixtures/repos_owner_repo_issues_${n}_comments.json"
|
||||||
|
done
|
||||||
|
: >"$ARRIVAL/fixtures/edits"
|
||||||
|
open_pr_out="$(
|
||||||
|
env PATH="$ARRIVAL/stub:$PATH" GH_FIXTURES="$ARRIVAL/fixtures" \
|
||||||
|
CEREMONY_FORGE=github ISSUEFLOW_NOW="$INOW" ISSUEFLOW_STALE_HOURS=1 \
|
||||||
|
REPO=owner/repo LABELS_CONF="$ARRIVAL/labels.conf" \
|
||||||
|
bash "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh" 2>&1
|
||||||
|
)"
|
||||||
|
check "the open-pull gather completes" 0 "" \
|
||||||
|
grep -qF 'issueflow: reconciled.' <<<"$open_pr_out"
|
||||||
|
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
|
||||||
|
grep -qE 'issue edit 50 .*--remove-label claimed' "$ARRIVAL/fixtures/edits"
|
||||||
|
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
|
||||||
|
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
|
||||||
|
"$ARRIVAL/fixtures/edits"
|
||||||
|
|
||||||
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
|
||||||
# the arrival path still kills the run loudly.
|
# the arrival path still kills the run loudly.
|
||||||
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
: >"$ARRIVAL/fixtures/repos_owner_repo_issues_91.json.error"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ export LC_ALL=C
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh
|
||||||
. actions/labels-reconcile/labels-reconcile.sh
|
. actions/labels-reconcile/labels-reconcile.sh
|
||||||
|
# This suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
||||||
|
# now, and forge_label_delete/forge_issue_comment/... resolve to the gh
|
||||||
|
# invocations those stubs already intercept (#188). main() selects a backend
|
||||||
|
# itself, but these probes call the pure functions directly, so the suite has
|
||||||
|
# to say which forge it is standing in.
|
||||||
|
forge_select github
|
||||||
|
# This suite predates test/harness.sh and carries its own expect(), so it does
|
||||||
|
# not get harness.sh's helper — define it here rather than pulling in a second
|
||||||
|
# assertion vocabulary. Strips the paging the shim injects so a fixture keyed
|
||||||
|
# on the logical endpoint still matches (#188).
|
||||||
|
forge_stub_path() {
|
||||||
|
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
|
||||||
|
}
|
||||||
load_config .github/labels.conf
|
load_config .github/labels.conf
|
||||||
set_required_bots codex-bot-andresmgsl
|
set_required_bots codex-bot-andresmgsl
|
||||||
|
|
||||||
|
|
@ -512,6 +525,7 @@ reconcile_probe() { # $1 = REPO_LABELS content → the log lines reconcile_pr em
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
||||||
run() { :; } # swallow mutations
|
run() { :; } # swallow mutations
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() { :; } # no network
|
gh() { :; } # no network
|
||||||
reconcile_pr 777 2>&1
|
reconcile_pr 777 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -610,6 +624,7 @@ ruling_probe() { # $1 = the PR's labels → the log lines reconcile_pr emits
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
PR_JSON='{"created_at":"2020-01-01T00:00:00Z"}'
|
||||||
run() { :; } # swallow mutations
|
run() { :; } # swallow mutations
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() { :; } # no network
|
gh() { :; } # no network
|
||||||
reconcile_pr 888 2>&1
|
reconcile_pr 888 2>&1
|
||||||
)
|
)
|
||||||
|
|
@ -659,6 +674,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
||||||
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
MERGEABLE=MERGEABLE CHECKS=SUCCESS
|
||||||
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
|
PR_JSON="$(jq -n --arg at "$(iso_at $((RNOW - 10 * 86400)))" '{created_at: $at}')"
|
||||||
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
|
run() { "$@"; } # mutations reach the stub and are recorded, not swallowed
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -671,6 +687,7 @@ ruling_sweep_probe() { # $1 = the PR's labels → reconcile_pr's log lines
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
endpoint="$(forge_stub_path "$endpoint")"
|
||||||
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$RTMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
# A missing fixture is an empty collection — projected through the
|
# A missing fixture is an empty collection — projected through the
|
||||||
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
|
# caller's --jq exactly like real gh, so '.[].foo' yields no lines.
|
||||||
|
|
@ -738,6 +755,10 @@ blind_main_probe() {
|
||||||
GITHUB_EVENT_NAME=schedule
|
GITHUB_EVENT_NAME=schedule
|
||||||
REPO=owner/repo
|
REPO=owner/repo
|
||||||
LABELS_CONF=.github/labels.conf
|
LABELS_CONF=.github/labels.conf
|
||||||
|
# This probe IS a GitHub board — say so at the forge boundary rather
|
||||||
|
# than leaving main()'s preflight to infer one (#188).
|
||||||
|
CEREMONY_FORGE=github
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = label ] && [ "$2" = list ]; then
|
if [ "$1" = label ] && [ "$2" = list ]; then
|
||||||
core_label_rows | cut -d'|' -f1
|
core_label_rows | cut -d'|' -f1
|
||||||
|
|
@ -821,10 +842,10 @@ expected_upserts="$({ core_label_rows; configured_label_rows .github/labels.conf
|
||||||
)
|
)
|
||||||
expect "a dispatch deletes the six in the same run as the upserts" \
|
expect "a dispatch deletes the six in the same run as the upserts" \
|
||||||
"$RETIRED_WANT" \
|
"$RETIRED_WANT" \
|
||||||
"$(sed -n 's/^gh label delete \(.*\) -R owner\/repo --yes$/\1/p' "$BOOT/happy")"
|
"$(sed -n 's/^forge_label_delete \(.*\)$/\1/p' "$BOOT/happy")"
|
||||||
expect "...and the recorded upsert set is unchanged from today's" \
|
expect "...and the recorded upsert set is unchanged from today's" \
|
||||||
"$expected_upserts" \
|
"$expected_upserts" \
|
||||||
"$(sed -n 's/^gh label create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
|
"$(sed -n 's/^forge_label_create \([^ ]*\) .*/\1/p' "$BOOT/happy")"
|
||||||
|
|
||||||
# -- a missing label is success: gh exits non-zero with not-found, and the
|
# -- a missing label is success: gh exits non-zero with not-found, and the
|
||||||
# guard keeps that from aborting the dispatch. Red without the guard.
|
# guard keeps that from aborting the dispatch. Red without the guard.
|
||||||
|
|
@ -895,7 +916,7 @@ boot_dry_probe() {
|
||||||
}
|
}
|
||||||
dry_out="$(boot_dry_probe)"
|
dry_out="$(boot_dry_probe)"
|
||||||
expect "DRY_RUN narrates each deletion" \
|
expect "DRY_RUN narrates each deletion" \
|
||||||
6 "$(grep -c '^labels: DRY_RUN: gh label delete' <<<"$dry_out")"
|
6 "$(grep -c '^labels: DRY_RUN: forge_label_delete' <<<"$dry_out")"
|
||||||
expect "...and performs none" \
|
expect "...and performs none" \
|
||||||
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
|
no "$(test -f "$BOOT/dry-real" && echo yes || echo no)"
|
||||||
|
|
||||||
|
|
@ -922,6 +943,7 @@ printf 'panel=bot-a bot-b bot-c\n' >"$EXEC/labels.conf"
|
||||||
exec_env() { # $1 = event name → the real script, executed under the PATH stub
|
exec_env() { # $1 = event name → the real script, executed under the PATH stub
|
||||||
: >"$EXEC/record"
|
: >"$EXEC/record"
|
||||||
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
|
env PATH="$EXEC/stub:$PATH" GH_RECORD="$EXEC/record" \
|
||||||
|
CEREMONY_FORGE=github \
|
||||||
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
|
REPO=owner/repo LABELS_CONF="$EXEC/labels.conf" GITHUB_EVENT_NAME="$1" \
|
||||||
bash actions/labels-reconcile/labels-reconcile.sh
|
bash actions/labels-reconcile/labels-reconcile.sh
|
||||||
}
|
}
|
||||||
|
|
@ -946,5 +968,51 @@ for ev in schedule pull_request_target; do
|
||||||
expect "...and deletes nothing" \
|
expect "...and deletes nothing" \
|
||||||
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
no "$(grep -q '^delete ' "$EXEC/record" && echo yes || echo no)"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4)
|
||||||
|
#
|
||||||
|
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does.
|
||||||
|
# Measured 2026-08-02: rig!140 listed all three panelists with all three
|
||||||
|
# verdicts in, and rig!146 still lists three while MERGED. Read raw on
|
||||||
|
# Forgejo, that pins a PR at state:bots-reviewing for life and stops
|
||||||
|
# blocker:unrequested from ever being true.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
HEAD_SHA=head1
|
||||||
|
REVIEWS_JSON="$(reviews \
|
||||||
|
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \
|
||||||
|
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \
|
||||||
|
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")"
|
||||||
|
|
||||||
|
expect "a head-current approval is no longer outstanding" "" \
|
||||||
|
"$(outstanding_requests "$BOT1")"
|
||||||
|
expect "a blocking verdict is not outstanding either — it is answered" "" \
|
||||||
|
"$(outstanding_requests "$BOT2")"
|
||||||
|
# The one that matters: an approval of an OLDER head is not a verdict on this
|
||||||
|
# head, so that reviewer still owes one. Treating STALE as answered would let
|
||||||
|
# a stale round read as complete.
|
||||||
|
expect "a stale approval still owes a verdict" "$BOT3" \
|
||||||
|
"$(outstanding_requests "$BOT3")"
|
||||||
|
expect "a reviewer who never reviewed still owes one" "nobody" \
|
||||||
|
"$(outstanding_requests "nobody")"
|
||||||
|
|
||||||
|
# The Forgejo shape, end to end: the field lists all three long after every
|
||||||
|
# verdict landed. Only the stale one may survive the filter.
|
||||||
|
expect "the never-cleared forgejo field collapses to who actually owes" \
|
||||||
|
"$BOT3" "$(outstanding_requests "$BOT1
|
||||||
|
$BOT2
|
||||||
|
$BOT3")"
|
||||||
|
|
||||||
|
# The GitHub shape: the field is already accurate, so the filter is a no-op
|
||||||
|
# on the set GitHub would have produced (term 5 — behaviour unchanged).
|
||||||
|
expect "on a github-shaped field the filter removes nothing" "nobody" \
|
||||||
|
"$(outstanding_requests "nobody")"
|
||||||
|
expect "an empty request list stays empty" "" "$(outstanding_requests "")"
|
||||||
|
|
||||||
|
# The summary and the gate belong at the TRUE end of the file. They sat in the
|
||||||
|
# middle until #188: eight outstanding_requests expects were appended after
|
||||||
|
# them, so a failure there printed FAIL, was left out of the totals, and the
|
||||||
|
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything
|
||||||
|
# appended below this line is ungated — so nothing goes below it.
|
||||||
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
. "$ROOT/test/harness.sh"
|
. "$ROOT/test/harness.sh"
|
||||||
|
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
||||||
|
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
||||||
|
# stubs already intercept (#188). Without this the verbs are simply undefined.
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
forge_select github
|
||||||
|
|
||||||
FACTS="$ROOT/lib/facts.sh"
|
FACTS="$ROOT/lib/facts.sh"
|
||||||
DECIDE="$ROOT/lib/decide.sh"
|
DECIDE="$ROOT/lib/decide.sh"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ set -u
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
# shellcheck source=test/harness.sh
|
# shellcheck source=test/harness.sh
|
||||||
source "$ROOT/test/harness.sh"
|
source "$ROOT/test/harness.sh"
|
||||||
|
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
|
||||||
|
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
|
||||||
|
# stubs already intercept (#188). Without this the verbs are undefined.
|
||||||
|
# shellcheck source=lib/forge.sh
|
||||||
|
. "$ROOT/lib/forge.sh"
|
||||||
|
forge_select github
|
||||||
# shellcheck source=lib/ruling.sh
|
# shellcheck source=lib/ruling.sh
|
||||||
source "$ROOT/lib/ruling.sh"
|
source "$ROOT/lib/ruling.sh"
|
||||||
|
|
||||||
|
|
@ -123,6 +129,7 @@ run() { "$@"; }
|
||||||
|
|
||||||
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
iso() { date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ; }
|
||||||
|
|
||||||
|
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
|
||||||
gh() {
|
gh() {
|
||||||
if [ "$1" = api ]; then
|
if [ "$1" = api ]; then
|
||||||
shift
|
shift
|
||||||
|
|
@ -135,6 +142,7 @@ gh() {
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
endpoint="$(forge_stub_path "$endpoint")"
|
||||||
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
file="$TMP/$(printf '%s' "$endpoint" | tr '/' '_').json"
|
||||||
[ -f "$file" ] || return 1
|
[ -f "$file" ] || return 1
|
||||||
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
if [ -n "$jqexpr" ]; then jq -r "$jqexpr" "$file"; else cat "$file"; fi
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue