forgedock 1.36.0 → 1.37.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.
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +8 -1
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -16,7 +16,8 @@ echo "$*" >> "$GH_LOG"
|
|
|
16
16
|
case "$1 $2" in
|
|
17
17
|
"api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
|
|
18
18
|
"api --paginate") [ -n "${CLAIM_FAIL:-}" ] && exit 1; cat "$COMMENTS_JSON"; exit 0 ;;
|
|
19
|
-
"issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues
|
|
19
|
+
"issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; N=$(( $(cat "$T_DIR/create.n" 2>/dev/null || echo 900) + 1 )); echo "$N" > "$T_DIR/create.n"; while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$T_DIR/created.$N.body"; shift; done; echo "https://github.com/o/r/issues/$N"; exit 0 ;;
|
|
20
|
+
"label create") exit 0 ;;
|
|
20
21
|
"issue comment") [ -n "${COMMENT_FAIL:-}" ] && exit 1; exit 0 ;;
|
|
21
22
|
"issue reopen") [ -n "${REOPEN_FAIL:-}" ] && exit 1; exit 0 ;;
|
|
22
23
|
"issue close") [ -n "${CLOSE_FAIL:-}" ] && exit 1; exit 0 ;;
|
|
@@ -25,6 +26,8 @@ case "$1 $2" in
|
|
|
25
26
|
while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
|
|
26
27
|
esac
|
|
27
28
|
if [ "$1" = api ]; then
|
|
29
|
+
case "$*" in "api repos/o/r/milestones"*) cat "$T_DIR/milestones.json"; exit 0 ;; esac
|
|
30
|
+
case "$*" in "api repos/o/r/issues/10 --jq"*) cat "$T_DIR/parent.milestone"; exit 0 ;; esac
|
|
28
31
|
case "$*" in *--jq*) cat "$ISSUE_BODY"; exit 0 ;; esac
|
|
29
32
|
n="${2##*/}"; [ -f "$T_DIR/issue.$n.json" ] && { cat "$T_DIR/issue.$n.json"; exit 0; }
|
|
30
33
|
echo "HTTP 404: Not Found" >&2; exit 1
|
|
@@ -37,7 +40,8 @@ PASS=0; FAIL=0
|
|
|
37
40
|
ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
|
|
38
41
|
bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
|
|
39
42
|
check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
|
|
40
|
-
reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN
|
|
43
|
+
reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json "$T/create.n"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN FORGE_FOLLOWUP_MILESTONE FORGE_FOLLOWUPS_DEFAULT_MILESTONE FORGE_EXTERNAL_PREREQ
|
|
44
|
+
echo '[]' > "$T/milestones.json"; : > "$T/parent.milestone"
|
|
41
45
|
echo '{"items":[]}' > "$SEARCH_JSON"; echo '[]' > "$COMMENTS_JSON"; }
|
|
42
46
|
# issue_json NUMBER STATE ASSOCIATION [PARENT]: REST view of a follow-up carrying the DEFERRED_FROM marker
|
|
43
47
|
issue_json() { printf '{"number":%s,"state":"%s","author_association":"%s","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #%s -->"}\n' "$1" "$2" "$3" "${4:-10}" > "$T/issue.$1.json"; }
|
|
@@ -198,10 +202,113 @@ check "dry-run: no comment/reopen/close/edit" "! grep -qE '^issue (comment|reope
|
|
|
198
202
|
# 18. #4626: a post-deploy why's [check:<id>] token is kept verbatim in the checklist line
|
|
199
203
|
reset
|
|
200
204
|
printf 'x\n' > "$ISSUE_BODY"
|
|
201
|
-
echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
|
|
202
|
-
printf '
|
|
205
|
+
echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
|
|
206
|
+
printf '{"number":77,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\\n<!-- FORGE:DEFERRED_FROM: #10 -->\\n### #10\\n<!-- FORGE:DEFERRED_CLASS: post-deploy -->"}\n' > "$T/issue.77.json"
|
|
207
|
+
printf -- '-\tpost-deploy: [check:login-smoke] after a day\t\t\tR3: soak\n' > "$T/check.tsv"
|
|
203
208
|
bash "$S" --parent 10 --repo o/r --items "$T/check.tsv" >/dev/null 2>&1
|
|
204
209
|
check "check token kept in the checklist line" "grep -q 'post-deploy: \[check:login-smoke\] after a day' '$ISSUE_BODY.edited'"
|
|
205
210
|
|
|
211
|
+
# 19. #4664: milestone inheritance
|
|
212
|
+
MS='[{"title":"M1","state":"open","due_on":"2026-01-01T00:00:00Z"},{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"M2","state":"open","due_on":"2026-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]'
|
|
213
|
+
reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M1
|
|
214
|
+
bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
|
|
215
|
+
check "milestone: open parent milestone inherited" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
|
|
216
|
+
reset; echo "$MS" > "$T/milestones.json"; echo M1 > "$T/parent.milestone"
|
|
217
|
+
bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
|
|
218
|
+
check "milestone: read from the parent when not passed" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
|
|
219
|
+
reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M0
|
|
220
|
+
bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
|
|
221
|
+
check "milestone: shipped (closed) one -> next open by due date" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
|
|
222
|
+
reset; echo '[{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]' > "$T/milestones.json"
|
|
223
|
+
export FORGE_FOLLOWUP_MILESTONE=M0 FORGE_FOLLOWUPS_DEFAULT_MILESTONE=Dflt
|
|
224
|
+
bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
|
|
225
|
+
check "milestone: shipped, no next -> followups.default_milestone" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone Dflt'"
|
|
226
|
+
reset; echo '[]' > "$T/milestones.json"
|
|
227
|
+
bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
|
|
228
|
+
check "milestone: parent without one -> none passed" "grep -q '^issue create' '$GH_LOG' && ! grep '^issue create' '$GH_LOG' | grep -q -- '--milestone'"
|
|
229
|
+
|
|
230
|
+
# 20. #4664: one issue per class, class labels
|
|
231
|
+
printf 'a/x.sh\twhy a\t\nb/y.sh\tpost-deploy: [check:smoke] later\t\t\nc/z.sh\texternal: needs a real credential\t\t\nd/w.sh\tops drill\t\tops\n-\tdefer: plain\t\t\tR9: plain item\n' > "$T/classes.tsv"
|
|
232
|
+
reset
|
|
233
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/classes.tsv"); RC=$?
|
|
234
|
+
check "classes: rc 0, one create per class (4)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 4 ]"
|
|
235
|
+
for c in engineering post-deploy external ops; do check "classes: label followup:$c applied" "grep '^issue create' '$GH_LOG' | grep -q -- '--label followup:$c'"; done
|
|
236
|
+
check "classes: external also labelled external-prerequisite" "grep '^issue create' '$GH_LOG' | grep -- 'followup:external' | grep -q -- '--label external-prerequisite'"
|
|
237
|
+
check "classes: engineering has the old title, others carry the class" "grep '^issue create' '$GH_LOG' | grep -q 'follow-up (ops): 1 deferred' && grep '^issue create' '$GH_LOG' | grep -q 'follow-up: 2 deferred'"
|
|
238
|
+
check "classes: telemetry lists every issue and class" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#901,#902,#903,#904 filed=4 classes=engineering,external,ops,post-deploy'"
|
|
239
|
+
check "classes: rows point at their class issue (a/x.sh and the R9 item share one)" "printf '%s\n' \"\$OUT\" | grep -q '^c/z.sh 90[0-9]\$' && [ \$(printf '%s\n' \"\$OUT\" | grep -E '^(a/x.sh|R9: plain item) ' | cut -f2 | sort -u | wc -l) -eq 1 ]"
|
|
240
|
+
check "classes: claim markers carry the class" "grep '^issue comment 10' '$GH_LOG' | grep -q 'class=external'"
|
|
241
|
+
# an existing engineering follow-up is not reused for another class
|
|
242
|
+
reset
|
|
243
|
+
printf 'x\n' > "$ISSUE_BODY"; issue_json 77 open OWNER; claim_json OWNER 77
|
|
244
|
+
printf 'c/z.sh\texternal: needs a credential\t\n' > "$T/ext.tsv"
|
|
245
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
|
|
246
|
+
check "classes: engineering follow-up #77 not reused for an external item" "[ $RC -eq 0 ] && grep -q '^issue create' '$GH_LOG' && ! grep -q '^issue edit 77' '$GH_LOG'"
|
|
247
|
+
# a labelled-class follow-up is reused for its class
|
|
248
|
+
reset
|
|
249
|
+
printf 'x\n' > "$ISSUE_BODY"
|
|
250
|
+
printf '{"number":78,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->\\n<!-- FORGE:DEFERRED_CLASS: external -->"}\n' > "$T/issue.78.json"; claim_json OWNER 78
|
|
251
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
|
|
252
|
+
check "classes: external follow-up #78 reused for external items" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 78' '$GH_LOG'"
|
|
253
|
+
|
|
254
|
+
# 21. #4664: requirement-text rows are rejected, nothing filed
|
|
255
|
+
for row in 'R1: a saved mapping per entity\twhy\t' 'R1: R2: a saved mapping\twhy\t' 'add a saved mapping\twhy\t' 'a/x.sh\twhy\t\tbogus'; do
|
|
256
|
+
reset; printf "$row\n" > "$T/bad.tsv"
|
|
257
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" 2>/dev/null); RC=$?
|
|
258
|
+
check "rejected row ($(printf "$row" | cut -c1-24)): rc 2, DEFERRED_TSV=INVALID, no gh call" "[ $RC -eq 2 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && [ ! -s '$GH_LOG' ]"
|
|
259
|
+
done
|
|
260
|
+
reset; printf 'a/x.sh\twhy\t\nR2: nope\twhy\t\n' > "$T/bad.tsv"
|
|
261
|
+
bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" >/dev/null 2>&1
|
|
262
|
+
check "one malformed row among valid ones files nothing" "[ $? -eq 2 ] && ! grep -q '^issue create' '$GH_LOG'"
|
|
263
|
+
# `-` + label is a valid requirement-level row; a doubled R-prefix in the label collapses; Affected Files lists only paths
|
|
264
|
+
reset; printf '%s\n' "$(printf -- '-\twhy\t\t\tR1: R2: a saved mapping')" "$(printf 'src/q.ts\twhy q\t')" > "$T/ok.tsv"
|
|
265
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ok.tsv"); RC=$?
|
|
266
|
+
check "label row accepted, prefix collapsed in stdout" "[ $RC -eq 0 ] && printf '%s\n' \"\$OUT\" | grep -q '^R1: a saved mapping 901'"
|
|
267
|
+
|
|
268
|
+
check "created body: Affected Files lists only paths, checklist keeps the collapsed label" "grep -q '^- \`src/q.ts\`\$' '$T/created.901.body' && ! grep -q '^- \`R1' '$T/created.901.body' && grep -q '^- \[ \] \`R1: a saved mapping\`' '$T/created.901.body' && ! grep -q 'R1: R2:' '$T/created.901.body'"
|
|
269
|
+
check "created body: DEFERRED_FROM and DEFERRED_CLASS markers" "grep -q 'FORGE:DEFERRED_FROM: #10 -->' '$T/created.901.body' && grep -q 'FORGE:DEFERRED_CLASS: engineering' '$T/created.901.body'"
|
|
270
|
+
# #4739: legal path characters (parentheses, space, =, #, :) are accepted; one such row no longer aborts the batch
|
|
271
|
+
reset; printf 'app/(marketing)/page.tsx\twhy\t\ndocs/My Notes.md\twhy\t\nsrc/a=b.ts\twhy\t\nsrc/#x.ts\twhy\t\nsrc/foo:bar.ts\twhy\t\n' > "$T/legal.tsv"
|
|
272
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/legal.tsv" 2>/dev/null); RC=$?
|
|
273
|
+
check "legal paths with ( ) space = # : are accepted and filed" "[ $RC -eq 0 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && grep -q '^issue create' '$GH_LOG' && grep -qF -- '- \`app/(marketing)/page.tsx\`' '$T/created.901.body' && grep -qF -- '- \`docs/My Notes.md\`' '$T/created.901.body' && grep -qF -- '- \`src/a=b.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/#x.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/foo:bar.ts\`' '$T/created.901.body'"
|
|
274
|
+
reset; printf -- '-\tpost-deploy: [check:login-smoke] soon\t\t\tR3: soak\n' > "$T/pd.tsv"
|
|
275
|
+
bash "$S" --parent 10 --repo o/r --items "$T/pd.tsv" >/dev/null 2>&1
|
|
276
|
+
check "post-deploy rollup: created with the lane marker, [check:] kept, section for the parent" "grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.901.body' && grep -q 'post-deploy: \[check:login-smoke\] soon' '$T/created.901.body' && grep -q '^### #10\$' '$T/created.901.body'"
|
|
277
|
+
|
|
278
|
+
# 23. #4724: N lanes -> ONE rollup; sections per parent; duplicates stored once; owner items never join it
|
|
279
|
+
reset
|
|
280
|
+
printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] first\t\t\tR1: the login page renders\n-\towner: [bucket:owner] rotate the key. why\t\t\tR2: rotate the credential\n' > "$T/l1.tsv"
|
|
281
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/l1.tsv"); RC=$?
|
|
282
|
+
check "rollup: lane 1 creates one external follow-up (#901) and one rollup (#902)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 2 ] && grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.902.body' && grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body'"
|
|
283
|
+
check "rollup: the owner item is not in the rollup" "! grep -q 'rotate the credential' '$T/created.902.body' && grep -q 'rotate the credential' '$T/created.901.body'"
|
|
284
|
+
check "rollup: telemetry has per-bucket counts" "printf '%s' \"\$OUT\" | grep -q 'buckets=owner:1,post-deploy:1'"
|
|
285
|
+
cp "$T/created.902.body" "$ISSUE_BODY"; : > "$GH_LOG"
|
|
286
|
+
echo '{"items":[{"number":902}]}' > "$SEARCH_JSON"
|
|
287
|
+
printf '{"number":902,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.902.json"
|
|
288
|
+
printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] again\t\t\tR7: The Login page renders!\n-\tpost-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 later\t\t\tR8: soak for a week\n' > "$T/l2.tsv"
|
|
289
|
+
OUT=$(bash "$S" --parent 11 --repo o/r --items "$T/l2.tsv"); RC=$?
|
|
290
|
+
check "rollup: lane 2 appends to #902, creates nothing" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 902' '$GH_LOG'"
|
|
291
|
+
check "rollup: own section for #11, soak item added" "grep -q '^### #11\$' '$ISSUE_BODY.edited' && grep -q 'R8: soak for a week' '$ISSUE_BODY.edited' && grep -q 'FORGE:DEFERRED_FROM: #11' '$ISSUE_BODY.edited'"
|
|
292
|
+
check "rollup: duplicate text from another lane stored once" "[ \$(grep -ci 'login page' '$ISSUE_BODY.edited') -eq 1 ] && printf '%s\n' \"\$OUT\" | grep -q '^R7: The Login page renders!.902'"
|
|
293
|
+
# a chain: parent 20 is itself a follow-up of #10 -> the items go under the root #10, no new issue
|
|
294
|
+
reset
|
|
295
|
+
cp "$T/created.902.body" "$ISSUE_BODY" 2>/dev/null || true
|
|
296
|
+
echo '{"items":[{"number":901}]}' > "$SEARCH_JSON"
|
|
297
|
+
printf '{"number":901,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.901.json"
|
|
298
|
+
printf '{"number":20,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}\n' > "$T/issue.20.json"
|
|
299
|
+
printf -- '-\tpost-deploy: [bucket:post-deploy] [check:b-smoke] chain\t\t\tR9: a chained check\n' > "$T/l3.tsv"
|
|
300
|
+
bash "$S" --parent 20 --repo o/r --items "$T/l3.tsv" >/dev/null 2>&1
|
|
301
|
+
check "rollup: a follow-up of a follow-up appends under its root parent, no new issue" "! grep -q '^issue create' '$GH_LOG' && grep -q 'a chained check' '$ISSUE_BODY.edited' && ! grep -q '^### #20' '$ISSUE_BODY.edited'"
|
|
302
|
+
# verify-now: its own per-parent follow-up, never the rollup
|
|
303
|
+
reset
|
|
304
|
+
printf -- '-\tpost-deploy: [bucket:verify-now] on default already\t\t\tR1: check it now\n' > "$T/vn.tsv"
|
|
305
|
+
OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/vn.tsv")
|
|
306
|
+
check "verify-now: separate per-parent issue, class verify-now, no rollup marker" "grep -q 'FORGE:DEFERRED_CLASS: verify-now' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body' && printf '%s' \"\$OUT\" | grep -q 'buckets=verify-now:1'"
|
|
307
|
+
# an owner bucket on a post-deploy class row is rerouted to external
|
|
308
|
+
reset
|
|
309
|
+
printf -- 'x/y.sh\towner: [bucket:owner] spend\t\tpost-deploy\n' > "$T/ow.tsv"
|
|
310
|
+
bash "$S" --parent 10 --repo o/r --items "$T/ow.tsv" >/dev/null 2>&1
|
|
311
|
+
check "owner bucket never lands in post-deploy, even when the row says so" "grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body'"
|
|
312
|
+
|
|
206
313
|
echo "passed=$PASS failed=$FAIL"
|
|
207
314
|
[ "$FAIL" -eq 0 ]
|
package/scripts/hotspots.sh
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# yq (optional) only to read forge.yaml. Run from the repo root.
|
|
7
7
|
#
|
|
8
8
|
# Usage: hotspots.sh [--base <ref>] [--] <path>...
|
|
9
|
-
# Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[]
|
|
9
|
+
# Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[] (lockfiles are always excluded)
|
|
10
10
|
# (also when forge.yaml or yq is missing); maintenance.generated_paths (forge#4519): shell globs of generated,
|
|
11
11
|
# bot-owned files, default [].
|
|
12
12
|
# Output (stdout, one JSON object):
|
|
@@ -57,7 +57,28 @@ if [ -n "$BASE" ]; then
|
|
|
57
57
|
MB=$(git merge-base "$BASE" HEAD) || die "no merge base for $BASE and HEAD"
|
|
58
58
|
fi
|
|
59
59
|
HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null && HAS_HEAD=1
|
|
60
|
-
|
|
60
|
+
# #4685: lockfiles are never hotspots (one dependency adds hundreds of lines); configured globs come on top
|
|
61
|
+
LOCKFILE_GLOBS='package-lock.json
|
|
62
|
+
*/package-lock.json
|
|
63
|
+
yarn.lock
|
|
64
|
+
*/yarn.lock
|
|
65
|
+
pnpm-lock.yaml
|
|
66
|
+
*/pnpm-lock.yaml
|
|
67
|
+
bun.lockb
|
|
68
|
+
*/bun.lockb
|
|
69
|
+
Cargo.lock
|
|
70
|
+
*/Cargo.lock
|
|
71
|
+
poetry.lock
|
|
72
|
+
*/poetry.lock
|
|
73
|
+
uv.lock
|
|
74
|
+
*/uv.lock
|
|
75
|
+
go.sum
|
|
76
|
+
*/go.sum
|
|
77
|
+
Gemfile.lock
|
|
78
|
+
*/Gemfile.lock
|
|
79
|
+
composer.lock
|
|
80
|
+
*/composer.lock'
|
|
81
|
+
GLOBS=$(printf '%s\n%s' "$LOCKFILE_GLOBS" "$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')")
|
|
61
82
|
GEN_GLOBS=$(printf '%s' "$GEN_CFG" | jq -r '.[]')
|
|
62
83
|
# 0 when $1 matches a newline-separated list of shell patterns in $2 (`*` crosses `/`)
|
|
63
84
|
glob_hit() {
|
package/scripts/hotspots.test.sh
CHANGED
|
@@ -116,5 +116,14 @@ if grep -q '"rev-list"' "$TMP/trace.json"; then ok; else bad "git trace not capt
|
|
|
116
116
|
if grep -qE '"(cat-file|show)"' "$TMP/trace.json"; then bad "blob read without --base"; else ok; fi
|
|
117
117
|
if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
|
|
118
118
|
|
|
119
|
+
# ---- #4685: lockfiles are excluded by default, with no forge.yaml; ordinary files are not ----
|
|
120
|
+
new_repo
|
|
121
|
+
lines web.lock 10; mkdir -p web; lines web/package-lock.json 10; lines app.py 10; commit base
|
|
122
|
+
git checkout -q -b feat2
|
|
123
|
+
lines more 900; cat more >> web/package-lock.json; cat more >> app.py; commit grow
|
|
124
|
+
OUT="$(bash "$S" --base main -- web/package-lock.json app.py)"
|
|
125
|
+
eq "lockfile excluded" "$(q '.files[0] | [.excluded,.growth]')" '[true,900]'
|
|
126
|
+
eq "ordinary file not excluded" "$(q '.files[1].excluded')" 'false'
|
|
127
|
+
|
|
119
128
|
echo "hotspots.test.sh: $PASS passed, $FAILN failed"
|
|
120
129
|
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/plan-validate-live.mjs — bootstrap-validate's dependency-order rules on LIVE GitHub state (#4659)
|
|
4
|
+
*
|
|
5
|
+
* `forgedock plan validate --live` and the parallel-start gate in `forgedock orchestrate`. Deterministic, no LLM.
|
|
6
|
+
* Milestone (train) order: due_on ascending (nulls last), then milestone number; GitHub has no explicit order field.
|
|
7
|
+
* Each cross-milestone `Depends on` edge is `ok` (dep in an earlier milestone and closed), `held` (earlier, not
|
|
8
|
+
* shipped) or `invalid` (dep in a later milestone). Edges are read with the engine's parseDependsOn.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { parseDependsOn } from "../bin/engine/phases.mjs";
|
|
13
|
+
|
|
14
|
+
/** Milestones in train order. Input: [{ title, number, due_on }]. */
|
|
15
|
+
export function orderMilestones(milestones) {
|
|
16
|
+
const due = (m) => (m?.due_on ? Date.parse(m.due_on) : Infinity);
|
|
17
|
+
return [...milestones].sort((a, b) => (due(a) - due(b) || 0) || (a.number ?? 0) - (b.number ?? 0)).map((m) => m.title);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ order: string[], issues: Array<{ number: number, milestone: string|null, body?: string, state?: string }> }} live
|
|
22
|
+
* @returns {{ ok: boolean, edges: Array<{ from: number, to: number, fromMilestone: string, toMilestone: string, state: "ok"|"held"|"invalid" }> }}
|
|
23
|
+
*/
|
|
24
|
+
export function classifyEdges({ order, issues }) {
|
|
25
|
+
const index = new Map(order.map((t, i) => [t, i]));
|
|
26
|
+
const byNumber = new Map(issues.map((it) => [it.number, it]));
|
|
27
|
+
const edges = [];
|
|
28
|
+
for (const it of issues) {
|
|
29
|
+
if (!it.milestone || !index.has(it.milestone)) continue;
|
|
30
|
+
for (const n of parseDependsOn(it.body, it.number)) {
|
|
31
|
+
const dep = byNumber.get(n);
|
|
32
|
+
if (!dep?.milestone || dep.milestone === it.milestone || !index.has(dep.milestone)) continue;
|
|
33
|
+
const state = index.get(dep.milestone) > index.get(it.milestone) ? "invalid"
|
|
34
|
+
: String(dep.state).toUpperCase() === "CLOSED" ? "ok" : "held";
|
|
35
|
+
edges.push({ from: it.number, to: n, fromMilestone: it.milestone, toMilestone: dep.milestone, state });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: !edges.some((e) => e.state === "invalid"), edges };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatLiveReport({ edges }) {
|
|
42
|
+
if (!edges.length) return "plan validate --live: no cross-milestone edges";
|
|
43
|
+
const lines = edges.map((e) => `${e.state}: #${e.from} (${e.fromMilestone}) depends on #${e.to} (${e.toMilestone})`);
|
|
44
|
+
const n = (s) => edges.filter((e) => e.state === s).length;
|
|
45
|
+
return `${lines.join("\n")}\nplan validate --live: ${n("ok")} ok, ${n("held")} held, ${n("invalid")} invalid`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const parse = (s) => { try { return JSON.parse(String(s)); } catch { return null; } };
|
|
49
|
+
|
|
50
|
+
/** Read milestones and all issues of `repo` through `gh` (injectable: async (args) => stdout). */
|
|
51
|
+
export async function readLive({ gh, repo }) {
|
|
52
|
+
const r = repo ? ["-R", repo] : [];
|
|
53
|
+
const ms = parse(await gh(["api", `repos/${repo || "{owner}/{repo}"}/milestones?state=all&per_page=100`, "--paginate", "--slurp"]));
|
|
54
|
+
const issues = parse(await gh(["issue", "list", ...r, "--state", "all", "--limit", "1000", "--json", "number,body,state,milestone"]));
|
|
55
|
+
if (!Array.isArray(ms) || !Array.isArray(issues)) throw new Error("could not read milestones/issues from GitHub");
|
|
56
|
+
return { order: orderMilestones(ms.flat()), issues: issues.map((i) => ({ ...i, milestone: i.milestone?.title ?? null })) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
|
|
61
|
+
* A dep is merged when it is CLOSED by a PR that is merged with that base. Unreadable issues are skipped (fail open:
|
|
62
|
+
* the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
|
|
63
|
+
* @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string }>>}
|
|
64
|
+
*/
|
|
65
|
+
export async function unmetDependencies({ issues, baseOf, gh, repo }) {
|
|
66
|
+
const r = repo ? ["-R", repo] : [];
|
|
67
|
+
const view = async (n, fields) => parse(await gh(["issue", "view", String(n), ...r, "--json", fields]));
|
|
68
|
+
const inBatch = new Set(issues);
|
|
69
|
+
const unmet = [];
|
|
70
|
+
for (const n of issues) {
|
|
71
|
+
let rec;
|
|
72
|
+
try { rec = await view(n, "body,milestone"); } catch { rec = null; }
|
|
73
|
+
if (!rec || !rec.milestone) continue;
|
|
74
|
+
const base = await baseOf(n);
|
|
75
|
+
if (!base) continue;
|
|
76
|
+
for (const dep of parseDependsOn(rec.body, n).filter((d) => !inBatch.has(d))) {
|
|
77
|
+
let why = null;
|
|
78
|
+
try {
|
|
79
|
+
const d = await view(dep, "state,closedByPullRequestsReferences");
|
|
80
|
+
if (String(d?.state).toUpperCase() !== "CLOSED") why = "not closed";
|
|
81
|
+
else {
|
|
82
|
+
let merged = false;
|
|
83
|
+
for (const pr of d.closedByPullRequestsReferences ?? []) {
|
|
84
|
+
const p = parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt"]));
|
|
85
|
+
if (p?.mergedAt && p.baseRefName === base) { merged = true; break; }
|
|
86
|
+
}
|
|
87
|
+
if (!merged) why = `closed but not merged into ${base}`;
|
|
88
|
+
}
|
|
89
|
+
} catch { why = "unreadable"; }
|
|
90
|
+
if (why) unmet.push({ issue: n, dep, base, why });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return unmet;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ghExec = async (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 100 * 1024 * 1024, timeout: 60000 });
|
|
97
|
+
|
|
98
|
+
/** CLI body: `plan validate --live [--repo owner/repo] [--json]`. Returns the exit code (1 when any edge is invalid). */
|
|
99
|
+
export async function runLive(argv, { gh = ghExec, out = (t) => process.stdout.write(`${t}\n`), err = (t) => process.stderr.write(`${t}\n`) } = {}) {
|
|
100
|
+
if (argv[0] !== "validate" || !argv.includes("--live")) { err("usage: forgedock plan validate --live [--repo owner/repo] [--json]"); return 2; }
|
|
101
|
+
const i = argv.indexOf("--repo");
|
|
102
|
+
const repo = i >= 0 ? argv[i + 1] : "";
|
|
103
|
+
let report;
|
|
104
|
+
try { report = classifyEdges(await readLive({ gh, repo })); } catch (e) { err(`plan validate --live: ${e.message}`); return 2; }
|
|
105
|
+
out(argv.includes("--json") ? JSON.stringify(report) : formatLiveReport(report));
|
|
106
|
+
return report.ok ? 0 : 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
110
|
+
process.exitCode = await runLive(process.argv.slice(2));
|
|
111
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// readiness.mjs — preview/deploy gate readiness from external prerequisites (forge#4660).
|
|
3
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
//
|
|
6
|
+
// Usage: readiness.mjs [--repo|-R owner/name] [--target preview|staging|prod] [--digest] [--issues file.json|-]
|
|
7
|
+
//
|
|
8
|
+
// A prerequisite is an open issue labelled `needs-human:external` whose body has a `gates:` line
|
|
9
|
+
// (`preview|staging-deploy|prod-deploy|feature-flag:<name>`, comma separated) and an `owner:` line.
|
|
10
|
+
// A code issue that carries its own `gates:` line names a milestone the gate still needs.
|
|
11
|
+
// Default output is the Readiness report; --digest prints the ordered human actions; --target prints
|
|
12
|
+
// DEPLOYABLE or NOT DEPLOYABLE for that target and exits 1 when not deployable.
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
export const EXTERNAL_LABEL = "needs-human:external";
|
|
18
|
+
const GATE_RE = /^(?:preview|staging-deploy|prod-deploy|feature-flag:[A-Za-z0-9._-]+)$/;
|
|
19
|
+
/** Deploy target -> the gate whose open prerequisites make it not deployable. */
|
|
20
|
+
export const TARGET_GATE = { preview: "preview", staging: "staging-deploy", prod: "prod-deploy" };
|
|
21
|
+
|
|
22
|
+
const labelNames = (i) => (Array.isArray(i?.labels) ? i.labels.map((l) => (typeof l === "string" ? l : l?.name)) : []);
|
|
23
|
+
|
|
24
|
+
/** Valid gate names from the issue body's `gates:` line(s); invalid entries are dropped. */
|
|
25
|
+
export function parseGates(body) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const m of String(body ?? "").matchAll(/^[ \t]*gates:[ \t]*(.*)$/gim)) {
|
|
28
|
+
for (const g of m[1].split(",").map((s) => s.trim().replace(/^`|`$/g, ""))) if (GATE_RE.test(g) && !out.includes(g)) out.push(g);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** `owner:` value, or null. */
|
|
34
|
+
export function parseOwner(body) {
|
|
35
|
+
const m = /^[ \t]*owner:[ \t]*(\S.*?)[ \t]*$/im.exec(String(body ?? ""));
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const deps = (body) => {
|
|
40
|
+
const m = /^[ \t]*depends on[ \t]*:?[ \t]*(.*)$/gim;
|
|
41
|
+
const out = new Set();
|
|
42
|
+
for (const x of String(body ?? "").matchAll(m)) for (const n of x[1].matchAll(/#(\d+)/g)) out.add(Number(n[1]));
|
|
43
|
+
return [...out];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `issues`: open issues { number, title, body, labels, milestone?, createdAt? }; `now`: ms.
|
|
48
|
+
* Returns { gates: { <gate>: { red, blocksMerge, prerequisites:[{number,title,owner,ageDays}], milestones:[title] } }, digest:[...] }.
|
|
49
|
+
*/
|
|
50
|
+
export function readiness(issues, now = Date.now()) {
|
|
51
|
+
const list = Array.isArray(issues) ? issues : [];
|
|
52
|
+
const prereqs = list.filter((i) => labelNames(i).includes(EXTERNAL_LABEL));
|
|
53
|
+
const gates = {};
|
|
54
|
+
const slot = (g) => (gates[g] ??= { red: true, blocksMerge: false, prerequisites: [], milestones: [] });
|
|
55
|
+
const entry = (i) => {
|
|
56
|
+
const t = Date.parse(i.createdAt);
|
|
57
|
+
return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null };
|
|
58
|
+
};
|
|
59
|
+
for (const i of prereqs) for (const g of parseGates(i.body)) slot(g).prerequisites.push(entry(i));
|
|
60
|
+
for (const i of list) {
|
|
61
|
+
if (labelNames(i).includes(EXTERNAL_LABEL)) continue;
|
|
62
|
+
const ms = typeof i.milestone === "string" ? i.milestone : i.milestone?.title;
|
|
63
|
+
if (!ms) continue;
|
|
64
|
+
for (const g of parseGates(i.body)) if (gates[g] && !gates[g].milestones.includes(ms)) gates[g].milestones.push(ms);
|
|
65
|
+
}
|
|
66
|
+
return { gates, digest: digest(prereqs, now) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Open prerequisites in dependency order (a prerequisite follows the open ones it depends on); `after` lists those. */
|
|
70
|
+
export function digest(prereqs, now = Date.now()) {
|
|
71
|
+
const byNum = new Map(prereqs.map((i) => [i.number, i]));
|
|
72
|
+
const after = (i) => deps(i.body).filter((n) => n !== i.number && byNum.has(n));
|
|
73
|
+
const done = new Set(), order = [], visiting = new Set();
|
|
74
|
+
const visit = (i) => {
|
|
75
|
+
if (done.has(i.number) || visiting.has(i.number)) return; // a cycle is cut, never loops
|
|
76
|
+
visiting.add(i.number);
|
|
77
|
+
for (const n of after(i)) visit(byNum.get(n));
|
|
78
|
+
visiting.delete(i.number);
|
|
79
|
+
done.add(i.number);
|
|
80
|
+
order.push(i);
|
|
81
|
+
};
|
|
82
|
+
[...prereqs].sort((a, b) => a.number - b.number).forEach(visit);
|
|
83
|
+
return order.map((i) => {
|
|
84
|
+
const t = Date.parse(i.createdAt);
|
|
85
|
+
return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), gates: parseGates(i.body),
|
|
86
|
+
ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null, after: after(i), unblocked: after(i).length === 0 };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `{ deployable, openPrerequisites }` for a target (preview|staging|prod). A feature-flag gate never affects it. */
|
|
91
|
+
export function deployable(report, target) {
|
|
92
|
+
const g = report.gates[TARGET_GATE[target]];
|
|
93
|
+
const open = g ? g.prerequisites : [];
|
|
94
|
+
return { deployable: open.length === 0, openPrerequisites: open };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const who = (p) => `#${p.number} ${p.title.slice(0, 60)} (owner: ${p.owner ?? "unassigned"}, age: ${p.ageDays ?? "?"}d)`;
|
|
98
|
+
|
|
99
|
+
export function formatReadinessReport(report) {
|
|
100
|
+
const names = Object.keys(report.gates).sort();
|
|
101
|
+
if (!names.length) return "Readiness: no open external prerequisites";
|
|
102
|
+
const lines = ["Readiness (open external prerequisites per gate):"];
|
|
103
|
+
for (const g of names) {
|
|
104
|
+
const x = report.gates[g];
|
|
105
|
+
const flag = g.startsWith("feature-flag:") ? " (flag-gated features may merge; gate stays red)" : "";
|
|
106
|
+
lines.push(` ${g}: RED${flag}`, ...x.prerequisites.map((p) => ` - ${who(p)}`));
|
|
107
|
+
lines.push(` code milestones needed: ${x.milestones.length ? x.milestones.join(", ") : "none recorded"}`);
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatReadinessDigest(report) {
|
|
113
|
+
if (!report.digest.length) return "Digest: no open human actions";
|
|
114
|
+
return ["Digest (human actions, dependency order):", ...report.digest.map((d, i) =>
|
|
115
|
+
` ${i + 1}. #${d.number} ${d.title.slice(0, 60)} [${d.gates.join(",") || "no gates"}] owner: ${d.owner ?? "unassigned"}${d.unblocked ? " - unblocked" : ` - after ${d.after.map((n) => `#${n}`).join(", ")}`}`)].join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function main(argv) {
|
|
119
|
+
const opt = (f) => { const k = argv.indexOf(f); return k >= 0 ? argv[k + 1] : undefined; };
|
|
120
|
+
const src = opt("--issues");
|
|
121
|
+
let issues;
|
|
122
|
+
if (src) issues = JSON.parse(readFileSync(src === "-" ? 0 : src, "utf8"));
|
|
123
|
+
else {
|
|
124
|
+
const repo = opt("--repo") ?? opt("-R");
|
|
125
|
+
const r = spawnSync("gh", ["issue", "list", ...(repo ? ["-R", repo] : []), "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,createdAt"], { encoding: "utf8" });
|
|
126
|
+
if (r.status !== 0) { console.error("readiness: gh issue list failed"); return 2; }
|
|
127
|
+
issues = JSON.parse(r.stdout || "[]");
|
|
128
|
+
}
|
|
129
|
+
const report = readiness(issues);
|
|
130
|
+
const target = opt("--target");
|
|
131
|
+
if (target) {
|
|
132
|
+
if (!TARGET_GATE[target]) { console.error("readiness: --target must be preview|staging|prod"); return 2; }
|
|
133
|
+
const d = deployable(report, target);
|
|
134
|
+
console.log(d.deployable ? `DEPLOYABLE ${target}` : [`NOT DEPLOYABLE ${target}: ${TARGET_GATE[target]} gate has ${d.openPrerequisites.length} open prerequisite(s)`, ...d.openPrerequisites.map((p) => ` - ${who(p)}`)].join("\n"));
|
|
135
|
+
return d.deployable ? 0 : 1;
|
|
136
|
+
}
|
|
137
|
+
console.log(argv.includes("--digest") ? formatReadinessDigest(report) : formatReadinessReport(report));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
|
|
@@ -232,6 +232,21 @@ dom_f API_DESIGN "router|routes"
|
|
|
232
232
|
# --- Phase 2: read-only checks + the trusted full-build config gate ---
|
|
233
233
|
P2_FINDINGS=""
|
|
234
234
|
p2() { P2_FINDINGS="${P2_FINDINGS}${1}"$'\n'; }
|
|
235
|
+
# --- review.context staleness (#4666): advisory, never blocks. Unreadable config/checker/gh = unknown, not stale ---
|
|
236
|
+
CTX_GENERATED_AT=""; CTX_STALE="false"; CTX_ADVISORY="false"
|
|
237
|
+
_rc=""; if type forge_root >/dev/null 2>&1; then _rc="$(forge_root 2>/dev/null)/bin/review-context.mjs"; fi
|
|
238
|
+
if [ "$CONFIG_READABLE" = "true" ] && [ -f "$_rc" ] && command -v node >/dev/null 2>&1; then
|
|
239
|
+
_rcj=$(node "$_rc" check --config "$CONFIG_FILE" --root "${TOP:-$(dirname "$CONFIG_FILE")}" --repo "$REPO" --json 2>/dev/null || true)
|
|
240
|
+
if printf '%s' "$_rcj" | jq -e . >/dev/null 2>&1; then
|
|
241
|
+
CTX_GENERATED_AT=$(printf '%s' "$_rcj" | jq -r '.generated_at // ""')
|
|
242
|
+
[ "$(printf '%s' "$_rcj" | jq -r '.stale')" = "true" ] && CTX_STALE="true"
|
|
243
|
+
[ "$(printf '%s' "$_rcj" | jq -r '.advisory')" = "true" ] && CTX_ADVISORY="true"
|
|
244
|
+
while IFS= read -r _l; do [ -n "$_l" ] && p2 "REVIEW_CONTEXT_STALE: $_l"; done <<EOFRC
|
|
245
|
+
$(printf '%s' "$_rcj" | jq -r '.reasons[]?.detail')
|
|
246
|
+
EOFRC
|
|
247
|
+
[ "$CTX_ADVISORY" = "true" ] && p2 "REVIEW_CONTEXT_STALE: context generated ${CTX_GENERATED_AT}, $(printf '%s' "$_rcj" | jq -r '.merged_since') PRs merged since (limit $(printf '%s' "$_rcj" | jq -r '.max_merged_prs')): advisory"
|
|
248
|
+
fi
|
|
249
|
+
fi
|
|
235
250
|
HAS_TS=$(printf '%s\n' "$FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
|
|
236
251
|
HAS_PY=$(printf '%s\n' "$FILES" | grep -E '\.py$' | head -1)
|
|
237
252
|
TS_BUILD=$(cfg .verification.commands.typescript.build); PY_FORMAT=$(cfg .verification.commands.python.format)
|
|
@@ -664,6 +679,7 @@ CTX=$(jq -n \
|
|
|
664
679
|
--argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
|
|
665
680
|
--argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
|
|
666
681
|
--arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
|
|
682
|
+
--arg cgen "$CTX_GENERATED_AT" --arg cstale "$CTX_STALE" --arg cadv "$CTX_ADVISORY" \
|
|
667
683
|
--arg rdepth "$REVIEW_DEPTH" --arg rweight "$REVIEW_WEIGHT" --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
|
|
668
684
|
--arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --slurpfile dfiles "$TMP_DIR/dfiles.json" \
|
|
669
685
|
--arg pname "$PROJECT_NAME" --rawfile pctx "$TMP_DIR/pctx.txt" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
|
|
@@ -684,7 +700,7 @@ CTX=$(jq -n \
|
|
|
684
700
|
contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
|
|
685
701
|
roster: {tier: $rtier, review_depth: $rdepth, review_weight: $rweight, reason: $rreason, agents: $agents},
|
|
686
702
|
delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles[0]},
|
|
687
|
-
config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
703
|
+
config: {project_name: $pname, project_context: $pctx, context_generated_at: $cgen, context_stale: ($cstale == "true"), context_advisory: ($cadv == "true"), scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
688
704
|
domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
|
|
689
705
|
templates: {source: $tsrc, base: $tbase, monolithic_catalog: $tmono},
|
|
690
706
|
index_slices: {auth: $i_auth, billing: $i_billing, database: $i_db, api: $i_api, frontend: $i_fe, infrastructure: $i_infra},
|
|
@@ -488,5 +488,22 @@ eq "oversized slices JSON is over 128 KiB" '.slices | tojson | length > 131072'
|
|
|
488
488
|
eq "oversized security slice keeps every path" '.slices.security.paths | length' 1100
|
|
489
489
|
eq "oversized full_files keeps every path" '.full_files | length' 1100
|
|
490
490
|
|
|
491
|
+
# --- 13. stale review.context (#4666): REVIEW_CONTEXT_STALE finding + config flags; a fresh context emits neither ---
|
|
492
|
+
printf '{"name":"app","dependencies":{"react":"18"}}\n' > "$REPO_DIR/package.json"
|
|
493
|
+
STALE_CFG="$WORK/stale.yaml"
|
|
494
|
+
printf 'project:\n name: Demo\nreview:\n tech_stack: "Next.js 15, React"\n context: "Repo is docs-only; no code yet."\n context_generated_at: "2026-01-01"\n' > "$STALE_CFG"
|
|
495
|
+
setup feat/x staging "services/api/auth.py" "+x = 1"
|
|
496
|
+
CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
|
|
497
|
+
rc_is "stale context does not block" 0
|
|
498
|
+
eq "stale context flagged in config" '.config.context_stale' true
|
|
499
|
+
eq "stale context carries its generation date" '.config.context_generated_at' 2026-01-01
|
|
500
|
+
eq "stale context emits REVIEW_CONTEXT_STALE (docs-only)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("docs-only"))] | length' 1
|
|
501
|
+
eq "stale context emits REVIEW_CONTEXT_STALE (tech_stack)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("Next.js"))] | length' 1
|
|
502
|
+
printf 'project:\n name: Demo\nreview:\n tech_stack: "React"\n context: "A React app."\n' > "$STALE_CFG"
|
|
503
|
+
CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
|
|
504
|
+
eq "fresh context is not stale" '.config.context_stale' false
|
|
505
|
+
eq "fresh context emits no REVIEW_CONTEXT_STALE" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:"))] | length' 0
|
|
506
|
+
rm -f "$REPO_DIR/package.json"
|
|
507
|
+
|
|
491
508
|
echo "review-prepare tests: $pass passed, $fail failed"
|
|
492
509
|
[ "$fail" -eq 0 ]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/settle-scope.mjs — scope freeze and convergence for a milestone's settle (#4665). Pure, no I/O, no LLM.
|
|
3
|
+
*
|
|
4
|
+
* Settle (run the batch, re-check buildable, repeat) freezes the milestone's issue set when it begins. Issues filed
|
|
5
|
+
* later are classified by source: decomposition children of a frozen issue stay in the milestone (the parent's
|
|
6
|
+
* contract) and join the measured set; deferred follow-ups go to the next milestone or the backlog; review findings
|
|
7
|
+
* and anything else are counted as growth but are not part of the measured set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
11
|
+
const FINDING_RE = /<!--\s*FORGE:REVIEW_FINDING\b|\breview[- ]finding\b/i;
|
|
12
|
+
|
|
13
|
+
export const SOURCES = ["decomposition-child", "deferred-followup", "review-finding", "other"];
|
|
14
|
+
|
|
15
|
+
/** The issue numbers open or closed in the milestone when settle begins. @param {Array<{number:number}|number>} issues */
|
|
16
|
+
export function freezeScope(issues) {
|
|
17
|
+
return new Set((issues ?? []).map((i) => (typeof i === "number" ? i : i?.number)).filter(Number.isSafeInteger));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ issue: { number: number, body?: string, parent?: number|null }, measured: Set<number>, decomposedParents?: Set<number> }} a
|
|
22
|
+
* `measured` is the frozen set plus the children already admitted; `decomposedParents` the issues that were decomposed.
|
|
23
|
+
* `issue.parent` is the decomposed parent when known (e.g. from the engine's decompose record).
|
|
24
|
+
* @returns {{ source: string, target: "milestone"|"next-milestone-or-backlog"|"unchanged" }}
|
|
25
|
+
*/
|
|
26
|
+
export function classifyNew({ issue, measured, decomposedParents = new Set() }) {
|
|
27
|
+
const body = String(issue?.body ?? "");
|
|
28
|
+
const parent = Number.isSafeInteger(issue?.parent) ? issue.parent : null;
|
|
29
|
+
if (parent !== null && measured.has(parent) && (decomposedParents.size === 0 || decomposedParents.has(parent))) {
|
|
30
|
+
return { source: "decomposition-child", target: "milestone" };
|
|
31
|
+
}
|
|
32
|
+
if (DEFERRED_FROM_RE.test(body)) return { source: "deferred-followup", target: "next-milestone-or-backlog" };
|
|
33
|
+
if (FINDING_RE.test(body)) return { source: "review-finding", target: "unchanged" };
|
|
34
|
+
return { source: "other", target: "unchanged" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Convergence on the frozen set plus the (transitive) children of decomposed issues in it.
|
|
39
|
+
* @param {{ frozen: Set<number>, current: Array<{ number: number, state?: string, body?: string, parent?: number|null }>, decomposedParents?: Set<number> }} a
|
|
40
|
+
* @returns {{ measured: number[], open: number[], grew: number, bySource: Record<string, number[]>, converged: boolean }}
|
|
41
|
+
*/
|
|
42
|
+
export function convergence({ frozen, current, decomposedParents = new Set() }) {
|
|
43
|
+
const measured = new Set(frozen);
|
|
44
|
+
const bySource = Object.fromEntries(SOURCES.map((s) => [s, []]));
|
|
45
|
+
const fresh = (current ?? []).filter((i) => !frozen.has(i.number));
|
|
46
|
+
// Children can be filed under children: admit until no more join.
|
|
47
|
+
let changed = true;
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
while (changed) {
|
|
50
|
+
changed = false;
|
|
51
|
+
for (const it of fresh) {
|
|
52
|
+
if (seen.has(it.number)) continue;
|
|
53
|
+
const c = classifyNew({ issue: it, measured, decomposedParents });
|
|
54
|
+
if (c.source !== "decomposition-child") continue;
|
|
55
|
+
seen.add(it.number); measured.add(it.number); bySource[c.source].push(it.number); changed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const it of fresh) {
|
|
59
|
+
if (seen.has(it.number)) continue;
|
|
60
|
+
bySource[classifyNew({ issue: it, measured, decomposedParents }).source].push(it.number);
|
|
61
|
+
}
|
|
62
|
+
const open = (current ?? []).filter((i) => measured.has(i.number) && String(i.state ?? "OPEN").toUpperCase() !== "CLOSED").map((i) => i.number);
|
|
63
|
+
const grew = SOURCES.reduce((n, s) => n + bySource[s].length, 0);
|
|
64
|
+
return { measured: [...measured], open, grew, bySource, converged: open.length === 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The STOP report text: how much the set grew and where the new issues came from. */
|
|
68
|
+
export function formatStopReport({ frozen, result, passes = null, limit = null }) {
|
|
69
|
+
const parts = SOURCES.filter((s) => result.bySource[s].length).map((s) => `${s} ${result.bySource[s].length} (${result.bySource[s].map((n) => `#${n}`).join(", ")})`);
|
|
70
|
+
const pass = passes !== null && limit !== null ? ` after ${passes}/${limit} passes` : "";
|
|
71
|
+
const open = result.open.map((n) => `#${n}`).join(", ") || "none";
|
|
72
|
+
return `settle STOP${pass}: ${result.open.length} open in the measured set (${open}); frozen ${frozen.size}, set grew by ${result.grew}`
|
|
73
|
+
+ (parts.length ? `: ${parts.join("; ")}` : "");
|
|
74
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Refresh the ForgeDock GitHub App installation token (1h lifetime)
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
Type=oneshot
|
|
6
|
+
# Edit the path to your ForgeDock checkout (or the persisted ~/.forge copy), then see docs/CONFIG.md "Bot token refresh".
|
|
7
|
+
ExecStart=%h/.forge/scripts/refresh-bot-token.sh
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Refresh the ForgeDock bot token every 45 minutes
|
|
3
|
+
|
|
4
|
+
[Timer]
|
|
5
|
+
OnBootSec=2min
|
|
6
|
+
OnUnitActiveSec=45min
|
|
7
|
+
# Run a missed refresh right after the host wakes or the user manager restarts.
|
|
8
|
+
Persistent=true
|
|
9
|
+
|
|
10
|
+
[Install]
|
|
11
|
+
WantedBy=timers.target
|