feat(forge): derive outstanding review requests from the head, not the field

Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.

Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.

So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.

A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.

Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.

Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.

Refs #188
This commit is contained in:
cluade-reviewer-andresmgsl 2026-08-02 19:48:09 +00:00
parent baf4a20571
commit f2d5fcd565
4 changed files with 84 additions and 1 deletions

View file

@ -187,6 +187,35 @@ set_required_bots() { # the PR author is recused by construction
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
# 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
@ -752,10 +781,13 @@ main() {
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')"
# Read AFTER the reviews, because the raw field is not portable: Forgejo
# never clears it, so it is intersected with who still owes a verdict on
# this head (#188 term 4). A no-op on GitHub, which clears it itself.
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")"
# mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while

View file

@ -28,6 +28,10 @@
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-reconcile` and `labels-scope` no longer exit 0 on a Forgejo

View file

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

View file

@ -970,3 +970,43 @@ for ev in schedule pull_request_target; do
done
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
# ---------------------------------------------------------------------------
# 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 "")"