forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  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 +356 -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 +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  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 +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  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 +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -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 +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -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
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-pre.test.sh — cases for scripts/review-staging-pre.sh (forge#4358): bundle PR set, open review-finding gate,
3
+ # 0B.1 scope. Since #4359 the script is the only copy of these blocks (the spec calls `forgedock review-staging`).
4
+ # gh is a PATH stub reading per-case fixtures; git runs against a throwaway repo with a local bare "origin".
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
+ PRE="$ROOT/scripts/review-staging-pre.sh"
10
+ PASS=0; FAILN=0
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ command -v jq >/dev/null 2>&1 || { echo "review-staging-pre.test.sh: jq missing, skipped"; exit 0; }
14
+ command -v git >/dev/null 2>&1 || { echo "review-staging-pre.test.sh: git 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
+
18
+ # (1) the blocks live only here: present in the script, absent from the spec
19
+ for b in bundle-pr-set bundle-pr-kind bundle-delta-credit bundle-scope-loop; do
20
+ grep -qx "# BEGIN $b" "$PRE" && grep -qx "# END $b" "$PRE" && ok || bad "block $b missing from review-staging-pre.sh"
21
+ grep -qx "# BEGIN $b" "$ROOT/commands/review-pr-staging.md" && bad "block $b still copied into commands/review-pr-staging.md" || ok
22
+ done
23
+
24
+ # (2) usage errors
25
+ bash "$PRE" bogus --repo o/r --pr 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown subcommand must exit 2"
26
+ bash "$PRE" gate --repo o/r --pr x --prs-file /dev/null >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "non-numeric --pr must exit 2"
27
+ bash "$PRE" gate --repo o/r --pr 9 --prs-file "$TMP/none" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unreadable --prs-file must exit 2 (never an empty bundle)"
28
+ bash "$PRE" scope --repo o/r --pr 9 --default main --staging staging --prs-file /dev/null --sd "$TMP/sd" --bogus 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown flag must exit 2"
29
+
30
+ # gh stub: fixtures in $FIX; posted comment bodies are appended to $FIX/posted
31
+ mkdir -p "$TMP/bin"
32
+ cat > "$TMP/bin/gh" <<'STUB'
33
+ #!/usr/bin/env bash
34
+ F="$FIX"; a="$*"
35
+ # fail-labels / fail-issues: the degraded-label / review-finding read fails (rate limit, 5xx, expired auth)
36
+ case "$a" in "pr view"*"--json labels"*) [ -f "$F/fail-labels" ] && exit 1 ;; "issue list"*) [ -f "$F/fail-issues" ] && exit 1 ;; esac
37
+ case "$1 $2" in
38
+ "pr comment") while [ $# -gt 0 ]; do [ "$1" = --body ] && { printf '%s\n@@\n' "$2" >> "$F/posted"; }; shift; done; exit 0 ;;
39
+ "pr diff") [ -f "$F/files-$3" ] && { cat "$F/files-$3"; exit 0; }; exit 1 ;;
40
+ "pr view")
41
+ case "$a" in *"--json labels"*) [ -f "$F/degraded-$3" ] && echo true || echo false; exit 0 ;; esac
42
+ [ -f "$F/pr-$3.json" ] && { cat "$F/pr-$3.json"; exit 0; }; exit 1 ;;
43
+ "issue list")
44
+ n=$(printf '%s' "$a" | sed -n 's/.*--search PR #\([0-9]*\).*/\1/p')
45
+ [ -f "$F/findings-$n.json" ] && cat "$F/findings-$n.json" || echo '[]'; exit 0 ;;
46
+ esac
47
+ if [ "$1" = api ]; then
48
+ case "$a" in
49
+ *"/comments"*) n=$(printf '%s' "$a" | sed -n 's#.*issues/\([0-9]*\)/comments.*#\1#p'); [ -f "$F/comments-$n.json" ] && cat "$F/comments-$n.json" || echo '[]'; exit 0 ;;
50
+ *"repos/o/r/issues/"*) n=$(printf '%s' "$a" | sed -n 's#.*repos/o/r/issues/\([0-9]*\).*#\1#p'); [ -f "$F/issue-$n" ] && echo issue || echo pr; exit 0 ;;
51
+ esac
52
+ fi
53
+ echo "gh stub: unhandled: $a" >&2; exit 1
54
+ STUB
55
+ chmod +x "$TMP/bin/gh"
56
+ export PATH="$TMP/bin:$PATH"
57
+ newfix() { FIX="$TMP/fix.$1"; rm -rf "$FIX"; mkdir -p "$FIX"; export FIX; }
58
+
59
+ # throwaway repo: main, then staging = merge of PR #5 (feature commit) + a direct commit citing issue #77
60
+ G() { git -C "$TMP/work" -c user.email=t@t -c user.name=t -c commit.gpgsign=false "$@"; }
61
+ git init -q --bare "$TMP/origin.git"
62
+ git init -q "$TMP/work"; G checkout -q -b main
63
+ echo base > "$TMP/work/base.txt"; G add -A; G commit -qm base; G remote add origin "$TMP/origin.git"; G push -q origin main
64
+ G checkout -q -b staging; G checkout -q -b feat
65
+ echo a > "$TMP/work/a.js"; G add -A; G commit -qm "feat: a"; FEAT=$(G rev-parse HEAD)
66
+ G checkout -q staging; G merge -q --no-ff feat -m "Merge pull request #5 from o/feat"; MERGE=$(G rev-parse HEAD)
67
+ mkdir -p "$TMP/work/.github/workflows"; echo b > "$TMP/work/b.js"; echo x > "$TMP/work/.github/workflows/ci.yml"
68
+ G add -A; G commit -qm "fix: thing (#77)"; DIRECT=$(G rev-parse HEAD); G push -q origin staging
69
+ run() { (cd "$TMP/work" && bash "$PRE" "$@"); }
70
+
71
+ # (3) bundle: the merge-subject PR is kept, the issue cited in a subject is skipped, the list is persisted
72
+ newfix bundle; : > "$FIX/issue-77"
73
+ OUT=$(run bundle --repo o/r --default main --staging staging --pr 9 --sd "$TMP/sd" 2>&1); RC=$?
74
+ [ "$RC" -eq 0 ] && ok || bad "bundle must exit 0 (got $RC: $OUT)"
75
+ [ "$(cat "$TMP/sd/9_bundle-prs.txt" 2>/dev/null)" = 5 ] && ok || bad "bundle must persist exactly PR 5 (got '$(cat "$TMP/sd/9_bundle-prs.txt" 2>/dev/null)')"
76
+ case "$OUT" in *"skipped): 77"*) ok ;; *) bad "bundle must report the skipped issue 77 (got '$OUT')" ;; esac
77
+
78
+ # (4) gate
79
+ printf '5\n' > "$TMP/prs"
80
+ newfix clean; OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
81
+ [ "$RC" -eq 0 ] && ok || bad "clean gate must exit 0 (got $RC)"
82
+ case "$OUT" in *"RESULT: PASS"*) ok ;; *) bad "clean gate must print RESULT: PASS" ;; esac
83
+ grep -qx '<!-- FORGE:GATE_PASS:TYPE=open-review-finding -->' "$FIX/posted" && head -1 "$FIX/posted" | grep -qx '<!-- FORGE:GATE_PASS -->' && ok || bad "clean gate must post the unchanged GATE_PASS body"
84
+ newfix degraded; : > "$FIX/degraded-5"
85
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
86
+ [ "$RC" -eq 1 ] && grep -q 'FORGE:GATE_FAILURE:TYPE=review-panel-integrity' "$FIX/posted" && ok || bad "degraded panel must block with review-panel-integrity (rc $RC)"
87
+ newfix finding; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
88
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
89
+ [ "$RC" -eq 1 ] && ok || bad "an open finding must block (rc $RC)"
90
+ case "$OUT" in *"RESULT: BLOCK DEPLOY"*) ok ;; *) bad "blocked gate must print RESULT: BLOCK DEPLOY" ;; esac
91
+ grep -q 'FORGE:GATE_FAILURE:TYPE=open-review-finding|FINDINGS=1' "$FIX/posted" && ok || bad "blocked gate must post the GATE_FAILURE marker with FINDINGS=1"
92
+ newfix unanchored; jq -n '[{number:41,title:"bug (PR #55)",body:"x"}]' > "$FIX/findings-5.json"
93
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 0 ] && ok || bad "a finding for another PR (#55) must not block #5"
94
+ newfix override; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
95
+ jq -n '[{body:"OVERRIDE: shipping with open findings — known",author_association:"OWNER",user:{type:"User",login:"o"}}]' > "$FIX/comments-9.json"
96
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
97
+ [ "$RC" -eq 0 ] && case "$OUT" in *"RESULT: PASS"*) true ;; *) false ;; esac && ok || bad "a trusted override must pass (rc $RC)"
98
+ newfix untrusted; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
99
+ jq -n '[{body:"OVERRIDE: shipping with open findings — x",author_association:"NONE",user:{type:"User",login:"z"}}]' > "$FIX/comments-9.json"
100
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "an untrusted override must not pass"
101
+ # forge#4470: the gate fails closed. A failed gh read or an empty bundle never posts GATE_PASS.
102
+ printf '101\n102\n' > "$TMP/prs2"
103
+ newfix ghfail; : > "$FIX/fail-labels"; : > "$FIX/fail-issues"
104
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs2" 2>&1); RC=$?
105
+ [ "$RC" -eq 1 ] && ok || bad "gh read failure blocks: gate must exit 1 when every gh read fails (rc $RC)"
106
+ case "$OUT" in *"RESULT: BLOCK DEPLOY"*) ok ;; *) bad "gh read failure blocks: must print RESULT: BLOCK DEPLOY (got '$OUT')" ;; esac
107
+ ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "gh read failure blocks: must not post GATE_PASS"
108
+ grep -q 'FORGE:GATE_FAILURE:TYPE=gate-read-failure' "$FIX/posted" 2>/dev/null && ok || bad "gh read failure blocks: must post TYPE=gate-read-failure"
109
+ newfix issuefail; : > "$FIX/fail-issues"
110
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
111
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "a failed review-finding read alone must block without GATE_PASS (rc $RC)"
112
+ newfix labelfail; : > "$FIX/fail-labels"
113
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
114
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "a failed degraded-label read alone must block without GATE_PASS (rc $RC)"
115
+ newfix readfailoverride; : > "$FIX/fail-issues"
116
+ jq -n '[{body:"OVERRIDE: shipping with open findings — known",author_association:"OWNER",user:{type:"User",login:"o"}}]' > "$FIX/comments-9.json"
117
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "a trusted override must not waive a read failure"
118
+ newfix emptyprs; : > "$TMP/prs-empty"
119
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs-empty" 2>&1); RC=$?
120
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "an empty prs-file must block without GATE_PASS (rc $RC: $OUT)"
121
+ newfix emptybundle
122
+ run bundle --repo o/r --default staging --staging staging --pr 8 --sd "$TMP/sd" >/dev/null 2>&1; RC=$?
123
+ [ "$RC" -ne 0 ] && ok || bad "an empty bundle PR set must exit non-zero"
124
+ newfix fetchfail
125
+ run bundle --repo o/r --default main --staging no-such-branch --pr 8 --sd "$TMP/sd" >/dev/null 2>&1; RC=$?
126
+ [ "$RC" -ne 0 ] && ok || bad "a failed git fetch must exit non-zero"
127
+
128
+ # (5) scope: PR 5 reviewed at its final head -> scoped; the direct commit and its files are unreviewed
129
+ newfix scope; H7=${FEAT:0:7}
130
+ jq -n --arg h "$FEAT" --arg m "$MERGE" '{headRefOid:$h,mergeCommit:{oid:$m},commits:[{oid:$h}],state:"MERGED",baseRefName:"staging",labels:[]}' > "$FIX/pr-5.json"
131
+ printf 'a.js\n' > "$FIX/files-5"
132
+ jq -n --arg r "<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha=$H7 -->" \
133
+ --arg g "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\n<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->' "$FEAT")" \
134
+ '[{body:$r,author_association:"NONE",user:{type:"Bot",login:"b"}},{body:$g,author_association:"NONE",user:{type:"Bot",login:"b"}}]' > "$FIX/comments-5.json"
135
+ OUT=$(run scope --repo o/r --default main --staging staging --pr 9 --prs-file "$TMP/prs" --sd "$TMP/sd" 2>&1); RC=$?
136
+ [ "$RC" -eq 0 ] && ok || bad "scope must exit 0 (got $RC: $OUT)"
137
+ J="$TMP/sd/9_scope.json"
138
+ [ "$(jq -r .scope_mode "$J" 2>/dev/null)" = scoped ] && ok || bad "a reviewed bundle must be scoped (got $(jq -c . "$J" 2>/dev/null); $OUT)"
139
+ [ "$(jq -r '.unreviewed_commits | join(" ")' "$J" 2>/dev/null)" = "$DIRECT" ] && ok || bad "only the direct commit is unreviewed (got $(jq -c .unreviewed_commits "$J" 2>/dev/null))"
140
+ jq -e '.unreviewed_files | index("b.js")' "$J" >/dev/null 2>&1 && ! jq -e '.unreviewed_files | index("a.js")' "$J" >/dev/null 2>&1 && ok || bad "unreviewed files must hold b.js and not a.js"
141
+ jq -e '.deploy_wiring_files | index(".github/workflows/ci.yml")' "$J" >/dev/null 2>&1 && ok || bad "workflow files are deploy wiring"
142
+ jq -e '.reviewed_pr_count == 1' "$J" >/dev/null 2>&1 && ok || bad "reviewed_pr_count must be 1"
143
+ grep -q '^SCOPE_MODE=scoped$' "$TMP/sd/9_scope.env" && grep -q '^5 a.js$' "$TMP/sd/9_bundle-pr-files.txt" && ok || bad "scope.env and bundle-pr-files.txt keep the spec's format"
144
+ case "$OUT" in *"SCOPE_MODE=scoped reviewed_prs=1"*) ok ;; *) bad "scope must print the one-line summary" ;; esac
145
+ newfix scopefail
146
+ run scope --repo o/r --default main --staging staging --pr 9 --prs-file "$TMP/prs" --sd "$TMP/sd" >/dev/null 2>&1
147
+ [ "$(jq -r .scope_mode "$TMP/sd/9_scope.json" 2>/dev/null)" = full ] && ok || bad "a failed gh pr view must widen scope to full"
148
+
149
+ echo "review-staging-pre.test.sh: passed=$PASS failed=$FAILN"
150
+ [ "$FAILN" -eq 0 ]