forgedock 1.30.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-post.test.sh — cases for scripts/review-staging-post.sh (forge#4359): the incomplete-panel guard, Phase 6.75
3
+ # agent delivery and the Phase 6.5 test-gate verdict handling (formerly extracted from commands/review-pr-staging.md fences).
4
+ # gh is a PATH stub reading per-case fixtures; trust decisions go through the real scripts/trusted-comments.sh.
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ POST="$ROOT/scripts/review-staging-post.sh"
10
+ SPEC="$ROOT/commands/review-pr-staging.md"
11
+ PASS=0; FAILN=0
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ command -v jq >/dev/null 2>&1 || { echo "review-staging-post.test.sh: jq missing, skipped"; exit 0; }
15
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
+ export FORGEDOCK_HOME="$ROOT" # resolve helpers from this checkout, not an ambient install
17
+ HEAD=0123456789abcdef0123456789abcdef01234567
18
+ OTHER=fedcba9876543210fedcba9876543210fedcba98
19
+ blk() { awk -v n="$1" '$0 == "# BEGIN " n {f=1; next} $0 == "# END " n {f=0} f' "$POST"; }
20
+
21
+ # (1) the fences now live only in the script: the blocks exist, the spec no longer carries them
22
+ for b in panel-guard delivery-check test-gate-extract test-gate-verdict; do
23
+ [ -n "$(blk "$b")" ] && ok || bad "block $b missing from review-staging-post.sh"
24
+ done
25
+ if grep -qE 'ACTUAL_AGENT_COUNT=\$\(gh api|MISSING_AGENT_COMMENTS=""|GATE_VERDICT_INVALID=0' "$SPEC"; then bad "the spec must not carry a copy of the panel-guard, delivery or verdict-handling fence"; else ok; fi
26
+ # 6.75 carries the findings-block lookahead; the 6.75 empty-list guard precedes the per-domain loop
27
+ blk delivery-check | grep -qF 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' && ok || bad "6.75 count must require the findings block"
28
+ G=$(blk delivery-check | grep -n 'launched-agent list is empty or unreadable' | head -1 | cut -d: -f1); L=$(blk delivery-check | grep -n '^for AGENT_DOMAIN in' | head -1 | cut -d: -f1)
29
+ [ -n "$G" ] && [ -n "$L" ] && [ "$G" -lt "$L" ] && ok || bad "6.75 empty-list guard must precede the for AGENT_DOMAIN loop"
30
+ # the producer never coerces an absent marker to SKIP; the catch-all arm never sets SKIP
31
+ if blk test-gate-extract | grep -q 'tail -1 || echo "SKIP"'; then bad "producer must not coerce an absent marker to SKIP"; else ok; fi
32
+ if blk test-gate-verdict | awk '/^ \*\)/,/;;/' | grep -q 'TEST_GATE_VERDICT="SKIP"'; then bad "catch-all arm must not set TEST_GATE_VERDICT=SKIP"; else ok; fi
33
+
34
+ # (2) usage errors
35
+ bash "$POST" bogus --repo o/r --pr 1 --sd "$TMP/u" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown subcommand must exit 2"
36
+ bash "$POST" delivery --repo o/r --pr x --sd "$TMP/u" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "non-numeric --pr must exit 2"
37
+ bash "$POST" delivery --repo o/r --pr 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "missing --sd must exit 2"
38
+ bash "$POST" test-gate --repo o/r --pr 1 --sd "$TMP/u" --output x --prs-file "$TMP/none" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unreadable --prs-file must exit 2"
39
+ bash "$POST" test-gate --repo o/r --pr 1 --sd "$TMP/u" --output x --prs-file /dev/null --posture lax >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown --posture must exit 2"
40
+
41
+ # gh stub: $FIX/comments.json is the PR's comment list; posted bodies and edits go to $FIX/posted / $FIX/edits
42
+ mkdir -p "$TMP/bin"
43
+ cat > "$TMP/bin/gh" <<'STUB'
44
+ #!/usr/bin/env bash
45
+ F="$FIX"; a="$*"
46
+ case "$1 $2" in
47
+ "pr comment") while [ $# -gt 0 ]; do [ "$1" = --body ] && printf '%s\n@@\n' "$2" >> "$F/posted"; shift; done; exit 0 ;;
48
+ "pr edit") printf '%s\n' "$a" >> "$F/edits"; exit 0 ;;
49
+ "label create") exit 0 ;;
50
+ "pr view") [ -f "$F/head" ] && { cat "$F/head"; exit 0; }; exit 1 ;;
51
+ esac
52
+ if [ "$1" = api ]; then case "$a" in *"/comments"*) cat "$F/comments.json" 2>/dev/null || exit 1; exit 0 ;; esac; fi
53
+ echo "gh stub: unhandled: $a" >&2; exit 1
54
+ STUB
55
+ printf '#!/bin/sh\nexit 1\n' > "$TMP/bin/yq" # no yq: defaults apply
56
+ chmod +x "$TMP/bin/gh" "$TMP/bin/yq"
57
+ export PATH="$TMP/bin:$PATH"
58
+ newfix() { FIX="$TMP/fix.$1"; rm -rf "$FIX"; mkdir -p "$FIX/sd"; export FIX; }
59
+ FB=$'<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->'
60
+ cm() { jq -n --arg b "$1" --arg t "${2:-Bot}" '{body:$b, author_association:"NONE", user:{type:$t, login:"x"}}'; }
61
+ agent() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\nReviewed-SHA: %s\n\n%s' "$1" "${2:-$HEAD}" "${3-$FB}"; }
62
+ comments() { jq -s '.' > "$FIX/comments.json"; }
63
+ deliver() { bash "$POST" delivery --repo o/r --pr 42 --sd "$FIX/sd" --head "${1:-$HEAD}" 2>&1; echo "rc=$?"; }
64
+
65
+ # (3) delivery: a complete panel passes and posts nothing
66
+ newfix full; printf 'security\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
67
+ { cm "$(agent security)"; cm "$(agent material-change)"; } | comments
68
+ OUT=$(deliver); case "$OUT" in *"DELIVERY: OK (2/2"*"rc=0") ok ;; *) bad "complete panel must pass (got: $OUT)" ;; esac
69
+ [ ! -s "$FIX/posted" ] && ok || bad "a complete panel must post nothing"
70
+ # a duplicate domain line does not inflate the selected count
71
+ printf 'security\nsecurity\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
72
+ OUT=$(deliver); case "$OUT" in *"rc=0") ok ;; *) bad "duplicate launched lines must count once (got: $OUT)" ;; esac
73
+
74
+ # (4) shortfalls: missing domain, block-less comment, untrusted author, other head -> panel-integrity failure
75
+ for c in missing noblock untrusted otherhead; do
76
+ newfix "$c"; printf 'security\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
77
+ case "$c" in
78
+ missing) cm "$(agent security)" | comments ;;
79
+ noblock) { cm "$(agent security)"; cm "$(agent material-change "$HEAD" 'Verdict: APPROVE, no findings.')"; } | comments ;;
80
+ untrusted) { cm "$(agent security)"; cm "$(agent material-change)" User; } | comments ;;
81
+ otherhead) { cm "$(agent security)"; cm "$(agent material-change "$OTHER")"; } | comments ;;
82
+ esac
83
+ OUT=$(deliver)
84
+ case "$OUT" in *"rc=1") ok ;; *) bad "$c: delivery must fail (got: $OUT)" ;; esac
85
+ if [ "$c" = noblock ]; then
86
+ case "$OUT" in *"missing findings comment(s) for: material-change"*) ok ;; *) bad "noblock: 6.75 must name the block-less domain (got: $OUT)" ;; esac
87
+ else
88
+ grep -qF '<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->' "$FIX/posted" 2>/dev/null && ok || bad "$c: shortfall must post GATE_FAILURE:TYPE=review-panel-integrity"
89
+ grep -qF 'review-degraded' "$FIX/edits" 2>/dev/null && ok || bad "$c: shortfall must label the PR review-degraded"
90
+ fi
91
+ done
92
+ # empty / absent launched list fails closed
93
+ newfix empty; : > "$FIX/sd/42_launched_review_agents"; cm "$(agent security)" | comments
94
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "empty launched list must fail (got: $OUT)" ;; esac
95
+ newfix none; cm "$(agent security)" | comments
96
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "absent launched list must fail (got: $OUT)" ;; esac
97
+ # unreadable comments count as 0 delivered
98
+ newfix unreadable; printf 'security\n' > "$FIX/sd/42_launched_review_agents"
99
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "unreadable comments must fail closed (got: $OUT)" ;; esac
100
+ # a malformed --head with no resolvable head refuses
101
+ newfix badhead; printf 'security\n' > "$FIX/sd/42_launched_review_agents"; cm "$(agent security)" | comments
102
+ OUT=$(deliver "n/a"); case "$OUT" in *"rc=1") ok ;; *) bad "an unresolvable head must fail closed (got: $OUT)" ;; esac
103
+ # ...but a malformed --head is re-resolved from gh when it can be
104
+ printf '%s\n' "$HEAD" > "$FIX/head"
105
+ OUT=$(deliver "n/a"); case "$OUT" in *"rc=0") ok ;; *) bad "a malformed --head must be re-resolved from gh (got: $OUT)" ;; esac
106
+
107
+ # (5) test gate
108
+ tg() { bash "$POST" test-gate --repo o/r --pr 42 --sd "$FIX/sd" --output "$FIX/out" --prs-file "$FIX/prs" "$@" 2>&1; echo "rc=$?"; }
109
+ tfix() { newfix "tg$1"; printf '7\n8\n' > "$FIX/prs"; echo '[]' > "$FIX/comments.json"; }
110
+ v() { sed -n 1p "$FIX/sd/42_test_gate_verdict"; }
111
+ tfix skip; printf 'noise\nFORGE:TEST_GATE:RESULT=SKIP\n' > "$FIX/out"
112
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "SKIP must continue (got: $OUT)" ;; esac
113
+ [ "$(v)" = SKIP ] && ok || bad "SKIP must be persisted (got $(v))"
114
+ [ ! -s "$FIX/posted" ] && ok || bad "SKIP posts no gate marker from the test gate"
115
+ tfix pass; printf 'FORGE:TEST_GATE:RESULT=BLOCK\nFORGE:TEST_GATE:RESULT=PASS\n' > "$FIX/out"
116
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "PASS must continue (got: $OUT)" ;; esac
117
+ [ "$(v)" = PASS ] && ok || bad "the last marker wins (got $(v))"
118
+ grep -qF '<!-- FORGE:GATE_PASS:TYPE=test-gate|BUNDLE=7 8 -->' "$FIX/posted" && ok || bad "PASS must post GATE_PASS:TYPE=test-gate with the bundle"
119
+ for m in missing nofile; do
120
+ tfix "$m"; [ "$m" = missing ] && printf 'all good, no marker\n' > "$FIX/out"
121
+ OUT=$(tg); case "$OUT" in *"RESULT: BLOCK DEPLOY"*"rc=1") ok ;; *) bad "$m marker must fail closed as BLOCK (got: $OUT)" ;; esac
122
+ [ "$(v)" = BLOCK ] && ok || bad "$m: persisted verdict must be BLOCK, never SKIP (got $(v))"
123
+ grep -qF '<!-- FORGE:GATE_FAILURE:TYPE=test-gate|BUNDLE=7 8 -->' "$FIX/posted" && ok || bad "$m: blocking BLOCK must post GATE_FAILURE"
124
+ done
125
+ tfix advisory; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
126
+ OUT=$(tg --posture advisory); case "$OUT" in *"rc=0") ok ;; *) bad "advisory BLOCK must continue (got: $OUT)" ;; esac
127
+ grep -q 'advisory' "$FIX/sd/42_test_gate_reason" && ok || bad "advisory reason must be persisted"
128
+ tfix override; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
129
+ cm 'OVERRIDE: shipping with test failures — flaky infra, tracked in #9' | comments
130
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "a trusted override must downgrade BLOCK (got: $OUT)" ;; esac
131
+ [ "$(v)" = PASS ] && grep -q 'downgraded to PASS by override' "$FIX/sd/42_test_gate_reason" && ok || bad "override must persist PASS with the reason"
132
+ tfix untrustedov; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
133
+ cm 'OVERRIDE: shipping with test failures — me' User | comments
134
+ OUT=$(tg); case "$OUT" in *"rc=1") ok ;; *) bad "an untrusted override must not downgrade BLOCK (got: $OUT)" ;; esac
135
+ tfix phrase; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
136
+ cm 'SHIP IT (a.b) because' | comments
137
+ OUT=$(tg --override-phrase 'SHIP IT (a.b)'); case "$OUT" in *"rc=0") ok ;; *) bad "a configured override phrase must be honoured literally (got: $OUT)" ;; esac
138
+ # stale verdict from a prior run is reset before reading
139
+ tfix stale; printf 'PASS\n' > "$FIX/sd/42_test_gate_verdict"; printf 'nothing\n' > "$FIX/out"
140
+ OUT=$(tg); [ "$(v)" = BLOCK ] && ok || bad "a stale persisted PASS must not survive a missing marker (got $(v))"
141
+
142
+ echo "review-staging-post.test.sh: passed=$PASS failed=$FAILN"
143
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-pre.sh — deterministic pre-dispatch of the staging->main review (forge#4358): the Phase 0A bundle PR set,
3
+ # the 0A open review-finding gate and the 0B.1 bundle review scope, moved out of commands/review-pr-staging.md.
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #
7
+ # Usage: review-staging-pre.sh bundle --repo R --default D --staging S --pr N --sd DIR
8
+ # review-staging-pre.sh gate --repo R --pr N --prs-file F
9
+ # review-staging-pre.sh scope --repo R --default D --staging S --pr N --prs-file F --sd DIR
10
+ # bundle fetches D and S, prints the bundle PR list and writes DIR/<N>_bundle-prs.txt (same file the spec persists).
11
+ # Exit 1 when the fetch fails or the bundle PR set is empty (fail closed: never an empty bundle to gate).
12
+ # gate open review-finding gate: posts FORGE:GATE_FAILURE / FORGE:GATE_PASS on PR N (bodies unchanged from the spec).
13
+ # Exit 0 = pass (or trusted override, last line "RESULT: PASS"), 1 = blocked. An empty --prs-file or any failed
14
+ # gh read blocks (TYPE=gate-read-failure, never waived by an override): no GATE_PASS without real reads (forge#4470).
15
+ # scope 0B.1: writes DIR/<N>_scope.env (printf %q, read by 7A), DIR/<N>_bundle-pr-files.txt and DIR/<N>_scope.json
16
+ # {scope_mode, reviewed_pr_count, reviewed_commits, unreviewed_commits, unreviewed_files, cross_pr_files,
17
+ # deploy_wiring_files}; prints the one-line SCOPE_MODE summary. Every failed read widens scope to full.
18
+ # Exit: 2 on a usage error.
19
+ #
20
+ # The `# BEGIN <name>` ... `# END <name>` blocks below are byte-identical copies of the spec's blocks ({GH_REPO} -> $GH_REPO);
21
+ # scripts/review-staging-pre.test.sh fails when they drift. Helpers (trusted-comments.sh, review-delta.sh) resolve through
22
+ # forge_resolve from trusted install roots only, never the working directory or the repo under review.
23
+ # Bash 3.2 portable except where the copied spec code already uses grep -P (0A Step 1).
24
+ set -u
25
+
26
+ CMD="${1:-}"; [ $# -gt 0 ] && shift
27
+ GH_REPO=""; DEFAULT_BRANCH=""; STAGING_BRANCH=""; PR_NUMBER=""; PRS_FILE=""; SD=""
28
+ while [ $# -gt 0 ]; do
29
+ [ $# -ge 2 ] || { echo "review-staging-pre: $1 requires a value" >&2; exit 2; }
30
+ case "$1" in
31
+ --repo) GH_REPO="$2" ;;
32
+ --default) DEFAULT_BRANCH="$2" ;;
33
+ --staging) STAGING_BRANCH="$2" ;;
34
+ --pr) PR_NUMBER="$2" ;;
35
+ --prs-file) PRS_FILE="$2" ;;
36
+ --sd) SD="$2" ;;
37
+ *) echo "review-staging-pre: unknown argument: $1" >&2; exit 2 ;;
38
+ esac
39
+ shift 2
40
+ done
41
+ case "$CMD" in bundle|gate|scope) ;; *) echo "review-staging-pre: unknown subcommand '${CMD}' (bundle|gate|scope)" >&2; exit 2 ;; esac
42
+ case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-staging-pre: --pr must be numeric" >&2; exit 2 ;; esac
43
+ case "$GH_REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/|'') echo "review-staging-pre: invalid --repo" >&2; exit 2 ;; */*) ;; *) echo "review-staging-pre: --repo must be owner/name" >&2; exit 2 ;; esac
44
+ if [ "$CMD" != gate ]; then
45
+ for _b in "$DEFAULT_BRANCH" "$STAGING_BRANCH"; do
46
+ case "$_b" in ''|-*|*[!A-Za-z0-9._/-]*) echo "review-staging-pre: --default/--staging must be branch names" >&2; exit 2 ;; esac
47
+ done
48
+ [ -n "$SD" ] || { echo "review-staging-pre: --sd is required" >&2; exit 2; }
49
+ mkdir -p "$SD" || exit 2
50
+ export FORGE_SCRATCHPAD="$SD"
51
+ fi
52
+ _SD="${SD:-${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}}"
53
+ ALL_PR_NUMBERS=""
54
+ if [ "$CMD" != bundle ]; then
55
+ [ -n "$PRS_FILE" ] || { echo "review-staging-pre: --prs-file is required" >&2; exit 2; }
56
+ [ -r "$PRS_FILE" ] || { echo "review-staging-pre: --prs-file $PRS_FILE is not readable (run bundle first)" >&2; exit 2; }
57
+ ALL_PR_NUMBERS=$(grep -E '^[0-9]+$' "$PRS_FILE")
58
+ fi
59
+
60
+ # --- trusted helper resolution (install roots only; never cwd) ---
61
+ # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
62
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
63
+
64
+ if [ "$CMD" = bundle ]; then
65
+ git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH || { echo "review-staging-pre: git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH failed: bundle PR set unknown (fail closed)" >&2; exit 1; }
66
+
67
+ # Step 1: Find all PR numbers in the staging→main bundle
68
+ # These are the PRs whose commits are included in staging but not yet in main
69
+ BUNDLE_PRS=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --oneline \
70
+ | grep -oE '#[0-9]+' \
71
+ | sort -u \
72
+ | tr -d '#')
73
+
74
+ # Also extract PR numbers from merge commit subjects (most reliable)
75
+ MERGE_PRS=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --merges --oneline \
76
+ | sed -n 's/.*pull request #\([0-9][0-9]*\).*/\1/p' \
77
+ | sort -u)
78
+
79
+ # BEGIN bundle-pr-set
80
+ # Conventional commit subjects cite issues too ("fix(x): ... (#123)"), so a subject-only number is kept only when it is not a confirmed issue.
81
+ # Merge-subject numbers are PRs by construction; an unclassifiable number is kept (fail open: it may be a PR). forge#4127
82
+ # BEGIN bundle-pr-kind
83
+ # Prints "pr" or "issue" for issue number ${2} in repo ${1}; prints nothing and returns 1 when the read fails or is malformed.
84
+ bundle_pr_kind() { local k; k=$(gh api "repos/${1}/issues/${2}" --jq 'if .pull_request then "pr" else "issue" end' 2>/dev/null) || return 1; case "$k" in pr|issue) printf '%s' "$k" ;; *) return 1 ;; esac; }
85
+ # END bundle-pr-kind
86
+ SUBJECT_PRS=""; SKIPPED_ISSUES=""
87
+ for n in $BUNDLE_PRS; do
88
+ case " $(echo $MERGE_PRS) " in *" $n "*) continue ;; esac
89
+ if [ "$(bundle_pr_kind "$GH_REPO" "$n")" = issue ]; then SKIPPED_ISSUES="$SKIPPED_ISSUES $n"; else SUBJECT_PRS="$SUBJECT_PRS $n"; fi
90
+ done
91
+ ALL_PR_NUMBERS=$(echo "$SUBJECT_PRS $MERGE_PRS" | tr ' ' '\n' | sort -u | grep -E '^[0-9]+$')
92
+ # END bundle-pr-set
93
+
94
+ echo "PRs in staging→main bundle: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')"
95
+ [ -z "$SKIPPED_ISSUES" ] || echo "Issue numbers cited in commit subjects (not bundle PRs, skipped):$SKIPPED_ISSUES"
96
+ # Persist for the 0B.1 fence (a later Bash call is a fresh shell); keyed by the PR number resolved in Phase -1.
97
+ if [ -n "${PR_NUMBER:-}" ]; then
98
+ _SD="${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}"; mkdir -p "$_SD"
99
+ printf '%s\n' "$ALL_PR_NUMBERS" > "$_SD/${PR_NUMBER}_bundle-prs.txt"
100
+ fi
101
+ [ -n "$ALL_PR_NUMBERS" ] || { echo "review-staging-pre: the staging→main bundle PR set is empty: nothing to gate (fail closed)" >&2; exit 1; }
102
+ exit 0
103
+ fi
104
+
105
+ if [ "$CMD" = gate ]; then
106
+
107
+ # Step 2: For each PR in the bundle, check for open review-finding issues and degraded panels
108
+ if [ -z "$ALL_PR_NUMBERS" ]; then
109
+ echo "⛔ DEPLOY BLOCKED — the bundle PR list ($PRS_FILE) is empty: nothing was checked, so the gate cannot pass."
110
+ echo "RESULT: BLOCK DEPLOY"
111
+ exit 1
112
+ fi
113
+ BLOCKING_FINDINGS=""
114
+ DEGRADED_REVIEWS=""
115
+ READ_FAILURES=""
116
+ for pr_num in $ALL_PR_NUMBERS; do
117
+ # Capture each gh read and test its own status (as the scope step does): a failed read is never "not degraded" / "no findings".
118
+ IS_REVIEW_DEGRADED=$(gh pr view "$pr_num" -R $GH_REPO --json labels \
119
+ --jq '[.labels[].name] | any(. == "review-degraded")' 2>/dev/null) || IS_REVIEW_DEGRADED=unreadable
120
+ case "$IS_REVIEW_DEGRADED" in true|false) ;; *) READ_FAILURES="$READ_FAILURES #${pr_num}(labels)" ;; esac
121
+ if [ "$IS_REVIEW_DEGRADED" = "true" ]; then
122
+ DEGRADED_REVIEWS="${DEGRADED_REVIEWS}
123
+ **PR #${pr_num}** has a \`review-degraded\` label and requires a full fresh-context re-review."
124
+ fi
125
+
126
+ # Search for open review-finding issues that reference this PR. The search is only a coarse
127
+ # candidate fetch (full-text, tokenized: it also hits hidden markers and longer numbers), so
128
+ # narrow it with the anchored FINDING_PR_FILTER: title suffix `PR #N)` or a `**Source**: PR #N` line.
129
+ FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
130
+ OPEN_FINDINGS=""
131
+ if _RAW=$(gh issue list -R $GH_REPO \
132
+ --label "review-finding" \
133
+ --state open \
134
+ --search "PR #${pr_num}" \
135
+ --limit 20 \
136
+ --json number,title,body 2>/dev/null) && printf '%s' "$_RAW" | jq -e 'type == "array"' >/dev/null 2>&1; then
137
+ OPEN_FINDINGS=$(printf '%s' "$_RAW" | jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) || READ_FAILURES="$READ_FAILURES #${pr_num}(findings)" # uses FINDING_PR_FILTER
138
+ else
139
+ READ_FAILURES="$READ_FAILURES #${pr_num}(findings)"
140
+ fi
141
+
142
+ if [ -n "$OPEN_FINDINGS" ]; then
143
+ BLOCKING_FINDINGS="${BLOCKING_FINDINGS}
144
+ **PR #${pr_num}** has open review findings:
145
+ ${OPEN_FINDINGS}"
146
+ fi
147
+ done
148
+
149
+ # An unreadable review state is not a known finding either: block before any override is consulted (forge#4470).
150
+ if [ -n "$READ_FAILURES" ]; then
151
+ echo "⛔ DEPLOY BLOCKED — could not read review state for bundle PR(s):${READ_FAILURES}"
152
+ echo "Re-run the gate once GitHub reads succeed."
153
+ echo "RESULT: BLOCK DEPLOY"
154
+ if [ -n "$PR_NUMBER" ]; then
155
+ gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
156
+ ## Deploy Gate: BLOCKED
157
+
158
+ **Gate**: open-review-finding (read failure)
159
+ **Unreadable**:${READ_FAILURES}
160
+
161
+ The degraded-label or open review-finding reads failed, so the gate cannot certify the bundle. Re-run the staging review once GitHub reads succeed.
162
+
163
+ <!-- FORGE:GATE_FAILURE:TYPE=gate-read-failure -->" 2>/dev/null || true
164
+ fi
165
+ exit 1
166
+ fi
167
+
168
+ # A degraded panel is a review-integrity failure, not a finding that an override may waive.
169
+ if [ -n "$DEGRADED_REVIEWS" ]; then
170
+ echo "⛔ DEPLOY BLOCKED — An included PR has an incomplete isolated review panel."
171
+ echo "$DEGRADED_REVIEWS"
172
+ if [ -n "$PR_NUMBER" ]; then
173
+ gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
174
+ ## Deploy Gate: BLOCKED
175
+
176
+ **Gate**: review-panel-integrity
177
+
178
+ ### Degraded Review Panels
179
+
180
+ ${DEGRADED_REVIEWS}
181
+
182
+ Re-run the complete review panel in a fresh session before deployment.
183
+
184
+ <!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->" 2>/dev/null || true
185
+ fi
186
+ exit 1
187
+ fi
188
+
189
+ # Step 3: Block deploy if open findings exist (unless human override present)
190
+ if [ -n "$BLOCKING_FINDINGS" ]; then
191
+ # Check for human override comment on the staging→main PR
192
+ if [ -n "$PR_NUMBER" ]; then
193
+ # Trusted authors only (scripts/trusted-comments.sh: OWNER/MEMBER/COLLABORATOR or a Bot), all comment pages,
194
+ # marker anchored at the start of the body. Fail closed: unresolvable script or unreadable comments = no override (#3492).
195
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
196
+ OVR_SCRIPT="$TRUSTED_SCRIPT"
197
+ OVR_COMMENTS=$(gh api --paginate "repos/$GH_REPO/issues/${PR_NUMBER}/comments" 2>/dev/null) || OVR_COMMENTS=""
198
+ if [ -n "$OVR_SCRIPT" ] && [ -n "$OVR_COMMENTS" ]; then
199
+ OVERRIDE=$(printf '%s' "$OVR_COMMENTS" | bash "$OVR_SCRIPT" count '^OVERRIDE: shipping with open findings' 2>/dev/null || echo 0)
200
+ else
201
+ OVERRIDE=0
202
+ fi
203
+ else
204
+ OVERRIDE=0
205
+ fi
206
+
207
+ if [ "${OVERRIDE:-0}" -eq 0 ]; then
208
+ echo "⛔ DEPLOY BLOCKED — Open review-finding issues exist for PRs in this bundle."
209
+ echo ""
210
+ echo "$BLOCKING_FINDINGS"
211
+ echo ""
212
+ echo "Options:"
213
+ echo " 1. Wait for the open findings to be fixed and merged to staging first."
214
+ echo " 2. Post a comment on this PR starting with \"OVERRIDE: shipping with open findings — <reason>\" to bypass this gate."
215
+ echo ""
216
+ echo "This gate exists to prevent deploying commits with known unfixed review findings."
217
+ echo "RESULT: BLOCK DEPLOY"
218
+
219
+ # Post structured FORGE:GATE_FAILURE comment for pipeline-health tracking
220
+ FINDING_COUNT=$(echo "$BLOCKING_FINDINGS" | grep -c '^\s*- #' || echo "unknown")
221
+ GATE_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
222
+ if [ -n "$PR_NUMBER" ]; then
223
+ gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
224
+ ## Deploy Gate: BLOCKED
225
+
226
+ **Gate**: open-review-finding
227
+ **Timestamp**: ${GATE_TIMESTAMP}
228
+ **Blocking findings**: ${FINDING_COUNT}
229
+
230
+ ### Open Review-Finding Issues
231
+
232
+ ${BLOCKING_FINDINGS}
233
+
234
+ ### Resolution
235
+
236
+ Fix the open findings above and merge fixes to staging before retrying the staging→main deploy.
237
+ To override (ship known issues with documented reason), post a comment starting with:
238
+ \`OVERRIDE: shipping with open findings — <reason>\`
239
+
240
+ <!-- FORGE:GATE_FAILURE:TYPE=open-review-finding|FINDINGS=${FINDING_COUNT} -->" 2>/dev/null || true
241
+ fi
242
+ exit 1
243
+ else
244
+ echo "⚠️ Open review findings exist but human override detected — proceeding with deploy."
245
+ echo "$BLOCKING_FINDINGS"
246
+ echo "Override comment found on PR #${PR_NUMBER}. Continuing."
247
+ fi
248
+ else
249
+ echo "✅ Open review-finding gate: PASSED — no open findings for PRs in this bundle."
250
+ # Post FORGE:GATE_PASS for symmetric observability — bypass is indistinguishable from clean pass without this
251
+ if [ -n "$PR_NUMBER" ]; then
252
+ gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_PASS -->
253
+ ## Deploy Gate: PASSED
254
+
255
+ **Gate**: open-review-finding check
256
+ **Result**: PASS — no open \`review-finding\` issues exist for PRs in this bundle.
257
+ **Bundle PRs**: ${ALL_PR_NUMBERS}
258
+ **Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
259
+
260
+ <!-- FORGE:GATE_PASS:TYPE=open-review-finding -->" 2>/dev/null || true
261
+ fi
262
+ fi
263
+ echo "RESULT: PASS"
264
+ exit 0
265
+ fi
266
+
267
+ # scope
268
+ SCOPE_MODE=full; REVIEWED_COMMITS=""; REVIEWED_MERGES=""; DEPLOY_WIRING_FILES=""; UNREVIEWED_COMMITS=""; UNREVIEWED_FILES=""; _LOG=""; CROSS_PR_FILES=""; REVIEWED_PR_COUNT=0
269
+ _PF="$_SD/${PR_NUMBER}_bundle-pr-files.txt"; : > "$_PF"
270
+ [ -n "${PR_NUMBER:-}" ] && [ -n "${GH_REPO:-}" ] || echo "SCOPE_MODE=full: scoping skipped (PR_NUMBER or GH_REPO empty after reload)"
271
+ [ -n "${ALL_PR_NUMBERS:-}" ] || echo "SCOPE_MODE=full: scoping skipped (ALL_PR_NUMBERS empty: Phase 0A did not persist a bundle list)"
272
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
273
+ [ -n "$TRUSTED_SCRIPT" ] || echo "SCOPE_MODE=full: scoping skipped (trusted-comments.sh not resolvable)"
274
+ REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
275
+ [ -n "$REVIEW_DELTA_SCRIPT" ] || echo "delta credit off: review-delta.sh not resolvable (stale-head PR commits stay UNREVIEWED)"
276
+ # BEGIN bundle-delta-credit
277
+ is_sha() { case "${1}" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]; }
278
+ # Partial credit for a PR whose head moved after its last complete review. Only the shared resolver (scripts/review-delta.sh) decides; nothing is re-derived here.
279
+ # Needs pr _HEAD _J _CM _SD GH_REPO TRUSTED_SCRIPT REVIEW_DELTA_SCRIPT in scope. Sets DELTA_COMMITS (space-separated PR commit oids already covered by a trusted complete panel) and DELTA_FULL (1 = whole PR covered by a trusted carried-forward base sync).
280
+ # Every failure (script unresolved, FULL, error, malformed output, force-push, untrusted or absent carried record, git read failure) leaves DELTA_COMMITS empty and DELTA_FULL=0: those commits stay UNREVIEWED.
281
+ bundle_delta_credit() {
282
+ local base labels top cf out l1 kind arg from to oid rc n
283
+ DELTA_COMMITS=""; DELTA_FULL=0
284
+ [ -n "$REVIEW_DELTA_SCRIPT" ] || return 0
285
+ base=$(printf '%s' "$_J" | jq -r '.baseRefName // empty') || return 0
286
+ labels=$(printf '%s' "$_J" | jq -r '[.labels[].name] | join(",")') || return 0
287
+ top=$(git rev-parse --show-toplevel 2>/dev/null) || return 0
288
+ [ -n "$base" ] && [ -n "$top" ] || return 0
289
+ cf=$(mktemp "$_SD/delta-comments.XXXXXX") || return 0
290
+ printf '%s' "$_CM" > "$cf" || { rm -f "$cf"; return 0; }
291
+ out=$(bash "$REVIEW_DELTA_SCRIPT" --pr "$pr" --head "$_HEAD" --base "$base" --trusted-script "$TRUSTED_SCRIPT" --comments-file "$cf" --labels "$labels" --repo "$GH_REPO" --repo-path "$top" 2>/dev/null) || out=""
292
+ rm -f "$cf"
293
+ l1=$(printf '%s\n' "$out" | sed -n 1p)
294
+ kind="${l1%% *}"; arg="${l1#* }"
295
+ case "$kind" in
296
+ DELTA)
297
+ from="${arg%%..*}"; to="${arg##*..}"
298
+ is_sha "$from" && [ "$to" = "$_HEAD" ] && [ "$from" != "$to" ] || return 0
299
+ for oid in $(printf '%s' "$_J" | jq -r '.commits[].oid'); do
300
+ git merge-base --is-ancestor "$oid" "$from" 2>/dev/null; rc=$?
301
+ [ "$rc" -eq 0 ] && DELTA_COMMITS="$DELTA_COMMITS $oid" # rc 1 (not covered) and rc >1 (error) both stay UNREVIEWED
302
+ done ;;
303
+ BASE_SYNC_ONLY)
304
+ is_sha "$arg" && [ "$arg" != "$_HEAD" ] || return 0
305
+ # A marker alone never counts: a TRUSTED FORGE:REVIEW-CARRIED record for this head must name the same reviewed sha (shape owned by review-pr.md carry_verified_from).
306
+ n=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "\\A(?=[\\s\\S]*\\nReviewed-SHA: ${_HEAD}\\r?(\\n|$))(?=[\\s\\S]*\\nCarried-From-SHA: ${arg}\\r?(\\n|$))<!-- FORGE:REVIEW-CARRIED -->" 2>/dev/null) || return 0
307
+ case "$n" in ''|*[!0-9]*) return 0 ;; esac
308
+ [ "$n" -ge 1 ] && DELTA_FULL=1 ;;
309
+ esac
310
+ return 0
311
+ }
312
+ # END bundle-delta-credit
313
+ # BEGIN bundle-pr-kind
314
+ # Prints "pr" or "issue" for issue number ${2} in repo ${1}; prints nothing and returns 1 when the read fails or is malformed.
315
+ bundle_pr_kind() { local k; k=$(gh api "repos/${1}/issues/${2}" --jq 'if .pull_request then "pr" else "issue" end' 2>/dev/null) || return 1; case "$k" in pr|issue) printf '%s' "$k" ;; *) return 1 ;; esac; }
316
+ # END bundle-pr-kind
317
+ # BEGIN bundle-scope-loop
318
+ _WHY=""
319
+ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "${ALL_PR_NUMBERS:-}" ] && [ -n "${PR_NUMBER:-}" ] && [ -n "${GH_REPO:-}" ]; then
320
+ SCOPE_MODE=scoped
321
+ for pr in $ALL_PR_NUMBERS; do
322
+ # A number 0A could not classify may be an issue (subjects cite issues): a confirmed issue is skipped, only a genuine read failure falls open (forge#4127).
323
+ _J=$(gh pr view "$pr" -R $GH_REPO --json headRefOid,mergeCommit,commits,state,baseRefName,labels 2>/dev/null) || {
324
+ if [ "$(bundle_pr_kind "$GH_REPO" "$pr")" = issue ]; then echo "0B.1: #$pr is an issue, not a bundle PR: skipped"; continue; fi
325
+ _WHY="gh pr view failed and #$pr is not a confirmed issue"; SCOPE_MODE=full; break; }
326
+ _HEAD=$(printf '%s' "$_J" | jq -r '.headRefOid')
327
+ case "$_HEAD" in *[!0-9a-f]*|"") SCOPE_MODE=full; break ;; esac # a malformed head id would make the sha match accept any comment (fail open): review everything
328
+ [ "${#_HEAD}" -eq 40 ] || [ "${#_HEAD}" -eq 64 ] || { SCOPE_MODE=full; break; }
329
+ _HEAD7=$(printf '%s' "$_HEAD" | cut -c1-7)
330
+ # Capture each gh read into a variable FIRST and test its own exit status: in a pipeline `||` only sees the last stage (no pipefail),
331
+ # so a failed gh read piped into another command would silently look like "no comments" / "no files".
332
+ _CM=$(gh api --paginate "repos/$GH_REPO/issues/$pr/comments" 2>/dev/null) || { SCOPE_MODE=full; break; }
333
+ _FILES=$(gh pr diff "$pr" -R $GH_REPO --name-only 2>/dev/null) || { SCOPE_MODE=full; break; }
334
+ # Reviewed = trusted FORGE:REVIEW_ROUTE (a real review mode) whose sha is the PR's FINAL head, plus at least one review-agent comment
335
+ # carrying the full-length `Reviewed-SHA: <head>` line and a findings block (same predicate as review-pr.md Phase 5; a 7-char substring or a comment with no findings block never counts), and no degraded/blocked review marker. A PR pushed to after its review has a stale sha: the exact-head gate gives it no credit, and only the delta branch below (shared resolver `scripts/review-delta.sh`) may credit the commits an earlier trusted complete panel covered or a trusted `FORGE:REVIEW-CARRIED` base sync carried; everything else stays UNREVIEWED.
336
+ _ROUTE=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW_ROUTE mode=(?!spec-evolution-blocked)[^ ]+ spec=review-pr.md sha=${_HEAD7} -->") || { SCOPE_MODE=full; break; }
337
+ _AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { SCOPE_MODE=full; break; }
338
+ _DEGRADED=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:(REVIEW_DEGRADED|REVIEW_BLOCKED)") || { SCOPE_MODE=full; break; }
339
+ case "$_ROUTE$_AGENT$_DEGRADED" in ''|*[!0-9]*) SCOPE_MODE=full; break ;; esac
340
+ if [ "$_ROUTE" -ge 1 ] && [ "$_AGENT" -ge 1 ] && [ "$_DEGRADED" -eq 0 ] && [ "$(printf '%s' "$_J" | jq '.commits|length')" -lt 100 ]; then
341
+ REVIEWED_PR_COUNT=$((REVIEWED_PR_COUNT+1))
342
+ REVIEWED_COMMITS="$REVIEWED_COMMITS $(printf '%s' "$_J" | jq -r '[.commits[].oid, .mergeCommit.oid // empty] | join(" ")')"
343
+ REVIEWED_MERGES="$REVIEWED_MERGES $(printf '%s' "$_J" | jq -r '.mergeCommit.oid // empty')"
344
+ elif [ "$_DEGRADED" -eq 0 ] && [ "$(printf '%s' "$_J" | jq '.commits|length')" -lt 100 ]; then
345
+ # Stale head: the exact-head gate failed, so ask the shared resolver how much of the PR an earlier trusted complete panel covered. Additive only: it can move commits out of UNREVIEWED, never into it.
346
+ bundle_delta_credit
347
+ if [ "$DELTA_FULL" -eq 1 ]; then
348
+ REVIEWED_PR_COUNT=$((REVIEWED_PR_COUNT+1))
349
+ REVIEWED_COMMITS="$REVIEWED_COMMITS $(printf '%s' "$_J" | jq -r '[.commits[].oid, .mergeCommit.oid // empty] | join(" ")')"
350
+ REVIEWED_MERGES="$REVIEWED_MERGES $(printf '%s' "$_J" | jq -r '.mergeCommit.oid // empty')"
351
+ elif [ -n "$DELTA_COMMITS" ]; then
352
+ REVIEWED_COMMITS="$REVIEWED_COMMITS$DELTA_COMMITS" # the PR's merge commit is not credited: it contains the post-review commits
353
+ fi
354
+ fi
355
+ printf '%s\n' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"
356
+ done
357
+ [ "$SCOPE_MODE" = scoped ] || echo "SCOPE_MODE=full: scoping fell open at bundle PR #$pr (${_WHY:-a gh read or marker count failed or returned malformed data})"
358
+ fi
359
+ # END bundle-scope-loop
360
+ if [ "$SCOPE_MODE" = "scoped" ]; then
361
+ # Cross-PR interactions: files touched by 2+ bundle PRs.
362
+ CROSS_PR_FILES=$(cut -d' ' -f2- "$_PF" | sort | uniq -d)
363
+ # Unreviewed commits: on staging, not in main, and not a commit of any reviewed PR (direct pushes, fast-lane fixes, post-review pushes).
364
+ # Merge commits are included: a merge that is not a reviewed PR's own merge commit may carry conflict-resolution content nobody reviewed.
365
+ _LOG=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --format=%H) || SCOPE_MODE=full
366
+ fi
367
+ if [ "$SCOPE_MODE" = "scoped" ]; then
368
+ for c in $_LOG; do
369
+ case " $REVIEWED_COMMITS " in *" $c "*) continue ;; esac
370
+ UNREVIEWED_COMMITS="$UNREVIEWED_COMMITS $c"
371
+ done
372
+ # Files of every unreviewed commit (--cc limits a merge commit to its conflict-resolution content), so 7B can test file membership.
373
+ # Each git read is captured and its own status tested (a failure here must widen scope, never narrow it).
374
+ UNREVIEWED_FILES=""
375
+ for c in $UNREVIEWED_COMMITS; do
376
+ _DT=$(git diff-tree --no-commit-id --name-only -r "$c") || { SCOPE_MODE=full; break; }
377
+ _DC=$(git diff-tree --no-commit-id --name-only -r --cc "$c") || { SCOPE_MODE=full; break; }
378
+ UNREVIEWED_FILES="$UNREVIEWED_FILES"$'\n'"$_DT"$'\n'"$_DC"
379
+ done
380
+ # A reviewed PR's own merge commit may carry conflict-resolution content its review never saw: count its --cc files as unreviewed too.
381
+ for c in $REVIEWED_MERGES; do
382
+ _DC=$(git diff-tree --no-commit-id --name-only -r --cc "$c") || { SCOPE_MODE=full; break; }
383
+ UNREVIEWED_FILES="$UNREVIEWED_FILES"$'\n'"$_DC"
384
+ done
385
+ UNREVIEWED_FILES=$(printf '%s\n' "$UNREVIEWED_FILES" | sed '/^$/d' | sort -u)
386
+ fi
387
+ # Deploy/runtime readiness files are always in scope: migrations, env, workflows, compose/infra, dependency manifests.
388
+ _DW=$(git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --name-only) || { SCOPE_MODE=full; _DW=""; }
389
+ DEPLOY_WIRING_FILES=$(printf '%s\n' "$_DW" | grep -E '(^|/)(migrations?/|\.github/workflows/|infra/|deploy/|docker-compose[^/]*\.ya?ml$|\.env[^/]*$|Dockerfile[^/]*$|package(-lock)?\.json$|pnpm-lock\.yaml$|yarn\.lock$|requirements[^/]*\.txt$|pyproject\.toml$|poetry\.lock$|go\.(mod|sum)$|Cargo\.(toml|lock)$)' || true)
390
+ # Persist the results for 7A (a later Bash call is a fresh shell). %q keeps multi-line file lists intact.
391
+ printf 'SCOPE_MODE=%q\nCROSS_PR_FILES=%q\nUNREVIEWED_FILES=%q\nDEPLOY_WIRING_FILES=%q\n' "$SCOPE_MODE" "${CROSS_PR_FILES:-}" "${UNREVIEWED_FILES:-}" "${DEPLOY_WIRING_FILES:-}" > "$_SD/${PR_NUMBER}_scope.env"
392
+ echo "SCOPE_MODE=$SCOPE_MODE reviewed_prs=$REVIEWED_PR_COUNT unreviewed_commits=$(echo $UNREVIEWED_COMMITS | wc -w) unreviewed_files=$(echo "$UNREVIEWED_FILES" | grep -c .) cross_pr_files=$(echo "$CROSS_PR_FILES" | grep -c .)"
393
+ # JSON twin of scope.env for the CLI: commit lists are space separated, file lists newline separated.
394
+ _jw() { printf '%s\n' "${1:-}" | tr ' ' '\n' | sed '/^$/d' | jq -R . | jq -s .; }
395
+ _jn() { printf '%s\n' "${1:-}" | sed '/^$/d' | jq -R . | jq -s .; }
396
+ jq -n --arg mode "$SCOPE_MODE" --argjson n "${REVIEWED_PR_COUNT:-0}" \
397
+ --argjson rc "$(_jw "$REVIEWED_COMMITS")" --argjson uc "$(_jw "$UNREVIEWED_COMMITS")" \
398
+ --argjson uf "$(_jn "${UNREVIEWED_FILES:-}")" --argjson cf "$(_jn "${CROSS_PR_FILES:-}")" --argjson dw "$(_jn "${DEPLOY_WIRING_FILES:-}")" \
399
+ '{scope_mode: $mode, reviewed_pr_count: $n, reviewed_commits: $rc, unreviewed_commits: $uc, unreviewed_files: $uf, cross_pr_files: $cf, deploy_wiring_files: $dw}' \
400
+ > "$_SD/${PR_NUMBER}_scope.json" || { echo "review-staging-pre: could not write ${PR_NUMBER}_scope.json" >&2; exit 1; }
401
+ exit 0