diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index cfdf887..1b05275 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -452,6 +452,15 @@ forge_pr_view() { }' } +# forge_pr_review_requests — logins with a live review request. +# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any +# review, so these rows are the exact live set rather than review history (#238). +forge_pr_review_requests() { + local n="${1:?forge_pr_review_requests: number required}" + forge_api --paginate "repos/$REPO/pulls/$n/reviews" \ + --jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u +} + forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; } # forge_label_create — an UPSERT, matching `gh label create --force` (#4743). diff --git a/lib/forge-github.sh b/lib/forge-github.sh index c2e268e..7a088ed 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -101,6 +101,12 @@ forge_pr_view() { gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup } +# forge_pr_review_requests — logins with a live review request. +forge_pr_review_requests() { + local n="${1:?forge_pr_review_requests: number required}" + forge_api "repos/$REPO/pulls/$n" --jq '.requested_reviewers[].login' | sort -u +} + # forge_label_list — every label name in the repo. forge_label_list() { gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index fd2c1c7..8349708 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -581,6 +581,76 @@ check "...never deriving them from reviews, as forgejo must" 1 "" \ unset -f gh . "$ROOT/lib/forge-forgejo.sh" +# --- forge_pr_review_requests: the forge's live request set ------------- +# A stale verdict is not itself evidence that anybody was re-requested. The +# backend reads the forge's request representation directly, so the state +# machine can distinguish the builder's ball from the panel's (#238). +review_requests_stub() { + fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS" +} + +# crew!96 after the builder pushed: two approvals and one requested change +# belong to the older head, but no live REQUEST_REVIEW row exists. +FAKE_REVIEWS_N=3 +FAKE_REVIEWS='[ + {"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"}, + {"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"}, + {"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"} +]' +review_requests_stub +check "supersedes: a stale approver who was never re-requested is not requested" 0 "" \ + eq "" forge_pr_review_requests 96 +check "supersedes: a merged PR whose requested_reviewers still lists the whole panel yields nobody" 0 "" \ + eq "" forge_pr_review_requests 146 + +# crew!97's opening request rows. Deliberately unordered with one duplicate: +# the public contract is sorted unique logins, independent of API row order. +FAKE_REVIEWS_N=5 +FAKE_REVIEWS='[ + {"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}, + {"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"}, + {"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"}, + {"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}, + {"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"} +]' +review_requests_stub +check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \ + eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97 + +FAKE_REVIEWS_N=2 +FAKE_REVIEWS='[ + {"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}, + {"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"} +]' +review_requests_stub +check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \ + eq "claude-bot" forge_pr_review_requests 97 + +FAKE_REVIEWS_N=1 +FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]' +review_requests_stub +check "supersedes: a login whose only row is REQUEST_REVIEW is requested" 0 "" \ + eq "kimi-bot" forge_pr_review_requests 97 + +# GitHub already exposes the exact live set on the PR object. Stub only the +# network boundary and assert the same backend-neutral output contract. +. "$ROOT/lib/forge-github.sh" +# shellcheck disable=SC2317 # invoked indirectly, by forge_api +gh() { + case "$1 $2" in + "api repos/o/r/pulls/55") + jq -r '.requested_reviewers[].login' <<'JSON' +{"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]} +JSON + ;; + *) return 1 ;; + esac +} +check "github returns requested_reviewers from the PR object unchanged" 0 "" \ + eq $'claude-bot\nglm-bot' forge_pr_review_requests 55 +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