forgedock 1.18.0 → 1.19.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/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -184,6 +184,52 @@ run "$GOOD" extract; rc_is "no trusted bodies" "$RC" 3; has "no trusted bodies"
|
|
|
184
184
|
reset; run "$NOTRUST" extract; rc_is "trust script missing" "$RC" 3; has "trust script missing" "scripts/trusted-comments.sh unresolvable or PR comments unreadable; refusing to read clean (fail closed)"
|
|
185
185
|
reset; run "$GOOD" extract; rc_is "comments unreadable" "$RC" 3; has "comments unreadable" "refusing to read clean"
|
|
186
186
|
|
|
187
|
+
# a findings summary list above the per-finding sections must not hide the scenario (summary bullets never start the section)
|
|
188
|
+
reset
|
|
189
|
+
cm MEMBER User "$(agent_body bug-hunter "$SHA" 'Findings summary:
|
|
190
|
+
- BUG-1: first problem
|
|
191
|
+
- BUG-2: second problem
|
|
192
|
+
- BUG-3: third problem
|
|
193
|
+
|
|
194
|
+
### BUG-1 first problem
|
|
195
|
+
**Failure scenario**: one breaks
|
|
196
|
+
### BUG-2 second problem
|
|
197
|
+
**Failure scenario**: two breaks
|
|
198
|
+
### BUG-3 third problem
|
|
199
|
+
**Fix efficacy**: n/a
|
|
200
|
+
' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|First problem alpha -->
|
|
201
|
+
<!-- FINDING:BUG-2|CONFIRMED|MEDIUM|b.py:10|Second problem bravo -->
|
|
202
|
+
<!-- FINDING:BUG-3|CONFIRMED|MEDIUM|c.py:18|Third problem charlie -->')"
|
|
203
|
+
flush; run "$GOOD" extract; rc_is "summary list" "$RC" 0
|
|
204
|
+
want "summary: first id keeps its scenario" '[.findings[] | select(.id == "BUG-1") | .scenario] | join(",")' findings.json "present"
|
|
205
|
+
want "summary: middle id keeps its scenario" '[.findings[] | select(.id == "BUG-2") | .scenario] | join(",")' findings.json "present"
|
|
206
|
+
want "summary: a section without a scenario stays absent" '[.findings[] | select(.id == "BUG-3") | .scenario] | join(",")' findings.json "absent"
|
|
207
|
+
|
|
208
|
+
# the same id repeated across two agents binds each finding to its own body: agent and scenario
|
|
209
|
+
reset
|
|
210
|
+
cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 alpha
|
|
211
|
+
**Failure scenario**: alpha scenario
|
|
212
|
+
' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Alpha problem in security -->')"
|
|
213
|
+
cm MEMBER User "$(agent_body billing "$SHA" '### BUG-1 omega
|
|
214
|
+
no scenario written here
|
|
215
|
+
' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|d.py:15|Omega problem in billing -->')"
|
|
216
|
+
flush; run "$GOOD" extract; rc_is "repeated id" "$RC" 0
|
|
217
|
+
want "repeated id: first binds its own agent" '[.findings[] | select(.title | test("Alpha")) | .agent] | join(",")' findings.json "security"
|
|
218
|
+
want "repeated id: first keeps its scenario" '[.findings[] | select(.title | test("Alpha")) | .scenario] | join(",")' findings.json "present"
|
|
219
|
+
want "repeated id: second binds its own agent" '[.findings[] | select(.title | test("Omega")) | .agent] | join(",")' findings.json "billing"
|
|
220
|
+
want "repeated id: second does not borrow the first scenario" '[.findings[] | select(.title | test("Omega")) | .scenario] | join(",")' findings.json "absent"
|
|
221
|
+
|
|
222
|
+
# the identical marker line repeated across two heads (DELTA) binds each occurrence to its own head's body
|
|
223
|
+
reset; mkctx 1 DELTA "$OLD_SHA"
|
|
224
|
+
cm MEMBER User "$(agent_body security "$SHA" '### BUG-1 same
|
|
225
|
+
no scenario at the current head
|
|
226
|
+
' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
|
|
227
|
+
cm MEMBER User "$(agent_body billing "$OLD_SHA" '### BUG-1 same
|
|
228
|
+
**Failure scenario**: earlier head scenario
|
|
229
|
+
' '<!-- FINDING:BUG-1|CONFIRMED|MEDIUM|a.py:2|Same marker both heads -->')"
|
|
230
|
+
flush; run "$GOOD" extract
|
|
231
|
+
want "repeated across heads: both occurrences extracted" '.counts.findings_extracted' findings.json "2"
|
|
232
|
+
|
|
187
233
|
# ======================= extract: 6B dedup =======================
|
|
188
234
|
reset
|
|
189
235
|
cm MEMBER User "$(agent_body security "$SHA" '### A-1 x
|
|
@@ -231,12 +277,20 @@ want "gate3: HIGH exempt" '.findings[] | select(.id == "N-2") | .route' findings
|
|
|
231
277
|
want "gate3: unreadable prose keeps the finding" '.findings[] | select(.id == "U-1") | .route' findings.json "file"
|
|
232
278
|
want "gate: introduced finding stays" '.findings[] | select(.id == "N-3") | .route' findings.json "file"
|
|
233
279
|
want "gate: counts" '[.counts.findings_preexisting, .counts.notes_demoted_no_scenario] | join(",")' findings.json "1,1"
|
|
280
|
+
# scope (#3975): recorded for every finding, any severity, computed from the added-line map
|
|
281
|
+
want "scope: MEDIUM far from added lines" '.findings[] | select(.id == "P-1") | .scope' findings.json "pre-existing"
|
|
282
|
+
want "scope: HIGH is recorded although never routed pre-existing" '.findings[] | select(.id == "P-2") | .scope' findings.json "pre-existing"
|
|
283
|
+
want "scope: HIGH near an added line" '.findings[] | select(.id == "N-2") | .scope' findings.json "introduced"
|
|
284
|
+
want "scope: MEDIUM near an added line" '.findings[] | select(.id == "N-3") | .scope' findings.json "introduced"
|
|
285
|
+
want "scope: every finding carries a valid scope" '[.findings[].scope | select(. == "introduced" or . == "pre-existing" or . == "unknown")] | length' findings.json "$(jq '.findings | length' "$WORK/out/findings.json")"
|
|
286
|
+
want "scope: introduced count" '.counts.findings_introduced' findings.json "$(jq '[.findings[] | select(.scope == "introduced")] | length' "$WORK/out/findings.json")"
|
|
234
287
|
# an unreadable files API classifies nothing as pre-existing
|
|
235
288
|
reset; echo '[]' > "$FIX/pr-files.json"
|
|
236
289
|
cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
|
|
237
290
|
**Failure scenario**: s
|
|
238
291
|
' '<!-- FINDING:P-1|LIKELY|MEDIUM|a.py:19|Pre-existing defect far from added lines -->')"; flush
|
|
239
292
|
run "$GOOD" extract; want "unreadable diff keeps" '.findings[0].route' findings.json "file"
|
|
293
|
+
want "unreadable diff: scope unknown" '.findings[0].scope' findings.json "unknown"
|
|
240
294
|
|
|
241
295
|
# gate 2 route table (ported from the retired spec sketch test): severity normalisation, deleted/renamed/patch-less files, files absent from the diff
|
|
242
296
|
reset
|
|
@@ -258,6 +312,9 @@ run "$GOOD" extract; rc_is "gate2 table" "$RC" 0
|
|
|
258
312
|
for pair in G-1:file G-2:pre-existing G-3:file G-4:file G-5:file G-6:file G-7:file G-8:file G-9:pre-existing G-10:pre-existing G-11:file G-12:pre-existing G-13:file; do
|
|
259
313
|
want "gate2 table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .route" findings.json "${pair#*:}"
|
|
260
314
|
done
|
|
315
|
+
for pair in G-1:introduced G-2:pre-existing G-3:pre-existing G-4:unknown G-7:pre-existing G-13:introduced G-12:pre-existing; do
|
|
316
|
+
want "scope table: ${pair%%:*}" ".findings[] | select(.id == \"${pair%%:*}\") | .scope" findings.json "${pair#*:}"
|
|
317
|
+
done
|
|
261
318
|
want "gate2 table: a file absent from the PR and from the head is stale (gate 1), not pre-existing" '[.findings[].id] | contains(["G-14"]) | not' findings.json "true"
|
|
262
319
|
|
|
263
320
|
# DELTA: the earlier reviewed head's findings are read as well
|
|
@@ -277,14 +334,53 @@ run "$GOOD" extract; rc_is "delta: earlier head unreadable" "$RC" 3; has "delta:
|
|
|
277
334
|
# clean review
|
|
278
335
|
reset; cm MEMBER User "$(agent_body security "$SHA" 'all good' 'no findings')"; flush
|
|
279
336
|
run "$GOOD" extract; has "clean" "STRUCTURED_FINDINGS=0"; has "clean" "CLEAN_STRUCTURED=true"
|
|
337
|
+
has "clean: no undelivered agent" "AGENTS_WITHOUT_BLOCK=
|
|
338
|
+
"
|
|
339
|
+
lacks() { case "$OUT" in *"$2"*) bad "$1: output has '$2'" ;; *) ok ;; esac; }
|
|
340
|
+
nob() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\n**Verdict: APPROVE - no findings.**\nReviewed-SHA: %s\n' "$1" "$2"; }
|
|
341
|
+
|
|
342
|
+
# verdict-only agent comment (no findings block): undelivered, never a finding, never clean
|
|
343
|
+
reset; cm MEMBER User "$(nob security "$SHA")"; flush
|
|
344
|
+
run "$GOOD" extract; rc_is "no-block agent" "$RC" 0
|
|
345
|
+
has "no-block agent: reported undelivered" "AGENTS_WITHOUT_BLOCK=security"
|
|
346
|
+
has "no-block agent: no finding created" "STRUCTURED_FINDINGS=0"
|
|
347
|
+
lacks "no-block agent: not clean" "CLEAN_STRUCTURED=true"
|
|
348
|
+
# mix: one clean (empty block) agent and one block-less agent
|
|
349
|
+
reset; cm MEMBER User "$(agent_body auth "$SHA" 'all good' '')"; cm MEMBER User "$(nob security "$SHA")"; flush
|
|
350
|
+
run "$GOOD" extract
|
|
351
|
+
has "mixed: block-less agent listed" "AGENTS_WITHOUT_BLOCK=security"
|
|
352
|
+
lacks "mixed: block-delivering agent not listed" "AGENTS_WITHOUT_BLOCK=auth"
|
|
353
|
+
lacks "mixed: block-delivering agent not listed (joined)" "security auth"
|
|
354
|
+
lacks "mixed: not clean" "CLEAN_STRUCTURED=true"
|
|
355
|
+
# SEC-1: a re-dispatched agent's later complete block clears its earlier block-less comment
|
|
356
|
+
reset; cm MEMBER User "$(nob security "$SHA")"; cm MEMBER User "$(agent_body security "$SHA" 'all good' '')"; flush
|
|
357
|
+
run "$GOOD" extract
|
|
358
|
+
lacks "redispatch: delivered domain not listed" "AGENTS_WITHOUT_BLOCK=security"
|
|
359
|
+
has "redispatch: clean" "CLEAN_STRUCTURED=true"
|
|
360
|
+
# SEC-2: START without END (truncated) is not delivered
|
|
361
|
+
reset; cm MEMBER User "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n<!-- REVIEW-FINDINGS-START -->\n' "$SHA")"; flush
|
|
362
|
+
run "$GOOD" extract
|
|
363
|
+
has "START without END: undelivered" "AGENTS_WITHOUT_BLOCK=security"
|
|
364
|
+
lacks "START without END: not clean" "CLEAN_STRUCTURED=true"
|
|
365
|
+
# a synthesized block must not mask a block-less raw agent comment
|
|
366
|
+
reset; cm MEMBER User "$(nob security "$SHA")"
|
|
367
|
+
cm MEMBER User "$(printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\nclean\nReviewed-SHA: %s\n' "$SHA")"; flush
|
|
368
|
+
run "$GOOD" extract
|
|
369
|
+
has "synth: block-less agent still reported" "AGENTS_WITHOUT_BLOCK=security"
|
|
370
|
+
lacks "synth: not clean" "CLEAN_STRUCTURED=true"
|
|
371
|
+
# an untrusted block-less comment is ignored (cannot park a review)
|
|
372
|
+
reset; cm MEMBER User "$(agent_body auth "$SHA" 'ok' '')"; cm NONE User "$(nob security "$SHA")"; flush
|
|
373
|
+
run "$GOOD" extract
|
|
374
|
+
lacks "untrusted block-less ignored" "AGENTS_WITHOUT_BLOCK=security"
|
|
375
|
+
has "untrusted block-less ignored: clean" "CLEAN_STRUCTURED=true"
|
|
280
376
|
|
|
281
377
|
# ======================= classify =======================
|
|
282
|
-
# mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text]
|
|
378
|
+
# mkfind <id> <conf> <sev> <file> <line> <title> [route] [agent] [scenario-text] [scope]
|
|
283
379
|
FJ=""
|
|
284
380
|
mkfind() {
|
|
285
|
-
FJ="${FJ:+$FJ,}$(jq -n -c --arg id "$1" --arg c "$2" --arg s "$3" --arg f "$4" --arg l "$5" --arg t "$6" --arg r "${7:-file}" --arg a "${8:-}" \
|
|
381
|
+
FJ="${FJ:+$FJ,}$(jq -n -c --arg id "$1" --arg c "$2" --arg s "$3" --arg f "$4" --arg l "$5" --arg t "$6" --arg r "${7:-file}" --arg a "${8:-}" --arg sc "${10:-}" \
|
|
286
382
|
'{id: $id, confidence: $c, severity: $s, loc: ($f + ":" + $l), file: $f, line_start: ($l | tonumber), line_end: ($l | tonumber), title: $t, agent: $a,
|
|
287
|
-
scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r}')"
|
|
383
|
+
scenario: "present", fix_efficacy: "", text: ($t + "\n" + $f), route: $r} + (if $sc == "" then {} else {scope: $sc} end)')"
|
|
288
384
|
}
|
|
289
385
|
putfind() { printf '{"schema_version":1,"review_sha":"%s","review_mode":"FULL","counts":{"findings_extracted":%s,"findings_dropped_stale":0,"findings_preexisting":%s,"notes_demoted_no_scenario":%s},"findings":[%s]}\n' \
|
|
290
386
|
"$SHA" "${1:-1}" "${2:-0}" "${3:-0}" "$FJ" > "$WORK/findings-in.json"; FJ=""; mkdir -p "$WORK/out"; cp "$WORK/findings-in.json" "$WORK/out/findings.json"; }
|
|
@@ -298,7 +394,7 @@ run "$GOOD" classify; rc_is "classify" "$RC" 0
|
|
|
298
394
|
has "classify: filed" "FINDINGS_FILED=1"
|
|
299
395
|
want "classify: note" '.findings[] | select(.id == "X-1") | .disposition | startswith("NOTE")' classified.json "true"
|
|
300
396
|
want "classify: issue" '.findings[] | select(.id == "X-2") | .class' classified.json "ISSUE"
|
|
301
|
-
posted_has "marker first line" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
|
|
397
|
+
posted_has "marker first line" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=1 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 introduced=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
|
|
302
398
|
posted_has "note list line" "- X-1 a.py:5 [POSSIBLE] Rename a local variable for clarity:"
|
|
303
399
|
# the later-head reader (prior_findings_tsv) must be able to parse the list line this script wrote
|
|
304
400
|
ROUND=$(grep '^- X-1' "$FIX/posted.log" | sed -n 's/^- [^ ]* \([^ ]*\) \[[A-Za-z?]*\] \(.*\): [^:]*$/\1\t\2/p')
|
|
@@ -343,6 +439,19 @@ mkfind I-1 CONFIRMED MEDIUM a.py 5 "Confirmed defect inside the changed file"; p
|
|
|
343
439
|
run "$GOOD" classify --auto-merge true; has "inpr round 2" "INPR_FIX=0"
|
|
344
440
|
posted_lacks "no second in-pr fix request" "FORGE:INPR_FIX"
|
|
345
441
|
want "inpr round 2 files it" '.findings[0].class' classified.json "ISSUE"
|
|
442
|
+
# scope (#3975): an introduced MEDIUM LIKELY finding in the diff is an in-PR fix; other scopes keep filing it
|
|
443
|
+
reset; mkctx 1 FULL "" 12; echo "false" > "$FIX/labels"; printf 'a.py\n' > "$FIX/files"; echo '[]' > "$FIX/comments.json"
|
|
444
|
+
mkfind S-1 LIKELY MEDIUM a.py 5 "Introduced likely defect in new code" file "" "" introduced
|
|
445
|
+
mkfind S-2 LIKELY MEDIUM a.py 6 "Pre-existing likely defect in old code" file "" "" pre-existing
|
|
446
|
+
mkfind S-3 LIKELY MEDIUM a.py 7 "Likely defect with unknown provenance" file "" "" unknown
|
|
447
|
+
mkfind S-4 LIKELY MEDIUM a.py 8 "Likely defect with no scope recorded"; putfind 4
|
|
448
|
+
run "$GOOD" classify --auto-merge true; has "scope inpr" "INPR_FIX=1"
|
|
449
|
+
want "scope: introduced LIKELY is INPR_FIX" '.findings[] | select(.id == "S-1") | .class' classified.json "INPR_FIX"
|
|
450
|
+
want "scope: pre-existing LIKELY is ISSUE" '.findings[] | select(.id == "S-2") | .class' classified.json "ISSUE"
|
|
451
|
+
want "scope: unknown LIKELY is ISSUE" '.findings[] | select(.id == "S-3") | .class' classified.json "ISSUE"
|
|
452
|
+
want "scope: absent LIKELY is never INPR_FIX" '.findings[] | select(.id == "S-4") | .class != "INPR_FIX"' classified.json "true"
|
|
453
|
+
want "scope: carried into classified.json" '.findings[] | select(.id == "S-1") | .scope' classified.json "introduced"
|
|
454
|
+
posted_has "NOTE_DISPOSITION records introduced count" " introduced=1 "
|
|
346
455
|
# an untrusted FORGE:INPR_FIX marker does not use up the round
|
|
347
456
|
reset; mkctx 1 FULL "" 12; printf 'a.py\n' > "$FIX/files"
|
|
348
457
|
cm NONE User "<!-- FORGE:INPR_FIX: round=1 head=${OLD_SHA} max_severity=MEDIUM findings=I-0 -->"; flush
|
|
@@ -458,6 +567,7 @@ mkfind F-3 CONFIRMED LOW a.py 2 "Low defect to file"
|
|
|
458
567
|
mkfind F-4 CONFIRMED LOW billing/pay.py 2 "Low billing defect"
|
|
459
568
|
mkfind F-5 CONFIRMED MEDIUM a.py 19 "Pre-existing medium defect" file-pre-existing
|
|
460
569
|
mkfind F-6 LIKELY LOW a.py 3 "A note, never filed" file
|
|
570
|
+
FJ=$(printf '%s' "[$FJ]" | jq -c 'map(if .id == "F-2" then . + {scope: "introduced"} elif .id == "F-5" then . + {scope: "pre-existing"} else . end) | .[]' | paste -sd, -)
|
|
461
571
|
putfind 6
|
|
462
572
|
cp "$WORK/out/findings.json" "$WORK/out/classified.json"
|
|
463
573
|
jq '.findings |= map(. + {class: (if .id == "F-6" then "NOTE" else "ISSUE" end), disposition: (if .id == "F-6" then "NOTE x" else "ISSUE x" end)})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
|
|
@@ -466,6 +576,9 @@ want "worklist: notes are not filed" '[.items[].id] | join(",")' worklist.json "
|
|
|
466
576
|
want "priority from severity (HIGH)" '.items[] | select(.id == "F-1") | .priority' worklist.json "priority:P1"
|
|
467
577
|
want "priority from severity (MEDIUM)" '.items[] | select(.id == "F-2") | .priority' worklist.json "priority:P2"
|
|
468
578
|
want "labels" '.items[] | select(.id == "F-2") | .labels | join(",")' worklist.json "review-finding,needs-validation,priority:P2"
|
|
579
|
+
want "worklist item carries scope" '.items[] | select(.id == "F-2") | .scope' worklist.json "introduced"
|
|
580
|
+
want "worklist item scope pre-existing" '.items[] | select(.id == "F-5") | .scope' worklist.json "pre-existing"
|
|
581
|
+
want "worklist item without scope is unknown" '.items[] | select(.id == "F-1") | .scope' worklist.json "unknown"
|
|
469
582
|
want "P3 batchable" '.items[] | select(.id == "F-3") | .batchable' worklist.json "true"
|
|
470
583
|
want "P3 on a billing path is not batchable" '.items[] | select(.id == "F-4") | .batchable' worklist.json "false"
|
|
471
584
|
want "pre-existing label only" '.items[] | select(.id == "F-5") | .labels | join(",")' worklist.json "pre-existing,priority:P2"
|
|
@@ -602,6 +715,54 @@ rc_is "inpr: untrusted request ignored" "$RC" 0
|
|
|
602
715
|
gate_reset; cm MEMBER User "<!-- FORGE:INPR_FIX: head=$SHA -->"; cm NONE User "<!-- FORGE:INPR_FIX_WAIVED: head=$SHA -->"; flush; mg "$GATE"
|
|
603
716
|
rc_is "inpr: untrusted waiver ignored" "$RC" 3
|
|
604
717
|
|
|
718
|
+
# --- degraded-gate gate (forge#3628): issue 42's newest trusted QUALITY_GATE / ACCEPTANCE_GATE name the degraded checks; PR 7's comments must cover each ---
|
|
719
|
+
QG_DEG=$'<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Degraded**: python.test — test run exercised nothing; blast-radius — check did not complete'
|
|
720
|
+
QG_OK=$'<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Degraded**: none'
|
|
721
|
+
AG_DEG=$'<!-- FORGE:ACCEPTANCE_GATE -->\n## Acceptance Gate — DEGRADED\n\n**Degraded checks (verified nothing)**: python.test — test run exercised nothing\n\n<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->'
|
|
722
|
+
AGM='<!-- FORGE:REVIEW-AGENT:security -->'
|
|
723
|
+
SEC_FULL="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n\n### Other\nx'
|
|
724
|
+
SEC_PART="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n\n### Other\nblast-radius is mentioned only outside the section'
|
|
725
|
+
SEC_GENERIC="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\nAll degraded paths were reviewed.\n'
|
|
726
|
+
SEC_STALE="$AGM"$'\nReviewed-SHA: '"$OLD_SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n'
|
|
727
|
+
SEC_NEG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: NOT reviewed\n- blast-radius: callers checked manually\n'
|
|
728
|
+
SEC_PLAIN=$'### Degraded Paths Reviewed\n- python.test: read the skipped DB tests by hand\n- blast-radius: callers checked manually\n'
|
|
729
|
+
SEC_AG="$AGM"$'\nReviewed-SHA: '"$SHA"$'\n\n### Degraded Paths Reviewed\n- python.test: covered by hand\n'
|
|
730
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
731
|
+
rc_is "degraded: no section" "$RC" 3; has "degraded: no section" "REVIEW_RESULT: status: BLOCKED, blocker: degraded-gate-unreviewed"
|
|
732
|
+
logged "degraded: no section needs-human" edits.log "needs-human"; logged "degraded: no section comment" issue-bodies.log "FORGE:DEGRADED_GATE_UNREVIEWED"; not_merged "degraded: no section"
|
|
733
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PART"; flush; mg "$GATE"
|
|
734
|
+
rc_is "degraded: partial coverage" "$RC" 3; has "degraded: partial coverage" "blocker: degraded-gate-unreviewed"; logged "degraded: partial names the gap" issue-bodies.log "not covered: blast-radius"; not_merged "degraded: partial"
|
|
735
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_GENERIC"; flush; mg "$GATE"
|
|
736
|
+
rc_is "degraded: generic section" "$RC" 3; has "degraded: generic section" "blocker: degraded-gate-unreviewed"; not_merged "degraded: generic"
|
|
737
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE User "$SEC_FULL"; flush; mg "$GATE"
|
|
738
|
+
rc_is "degraded: untrusted section ignored" "$RC" 3; has "degraded: untrusted section ignored" "blocker: degraded-gate-unreviewed"
|
|
739
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_STALE"; flush; mg "$GATE"
|
|
740
|
+
rc_is "degraded: stale-head section ignored" "$RC" 3; has "degraded: stale-head section ignored" "blocker: degraded-gate-unreviewed"
|
|
741
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_NEG"; flush; mg "$GATE"
|
|
742
|
+
rc_is "degraded: negated item is not coverage" "$RC" 3; logged "degraded: negated item names the gap" issue-bodies.log "not covered: python.test"
|
|
743
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_PLAIN"; flush; mg "$GATE"
|
|
744
|
+
rc_is "degraded: plain comment without review marker ignored" "$RC" 3
|
|
745
|
+
gate_reset; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_FULL"; flush; mg "$GATE"
|
|
746
|
+
rc_is "degraded: full per-check section" "$RC" 0; has "degraded: full per-check section" "MERGE_OUTCOME=merged"; merged "degraded: full"
|
|
747
|
+
gate_reset; cm Bot Bot "$QG_DEG"; flush "$FIX/comments-42.json"; cm NONE Bot "$SEC_FULL"; flush; mg "$GATE"
|
|
748
|
+
rc_is "degraded: bot-authored gate and section" "$RC" 0
|
|
749
|
+
gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
750
|
+
rc_is "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "$RC" 3; has "degraded: ACCEPTANCE_GATE:DEGRADED unreviewed" "blocker: degraded-gate-unreviewed"
|
|
751
|
+
gate_reset; cm MEMBER User "$AG_DEG"; flush "$FIX/comments-42.json"; cm MEMBER User "$SEC_AG"; flush; mg "$GATE"
|
|
752
|
+
rc_is "degraded: ACCEPTANCE_GATE:DEGRADED reviewed" "$RC" 0
|
|
753
|
+
gate_reset; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
754
|
+
rc_is "degraded: Degraded none is unaffected" "$RC" 0; has "degraded: Degraded none" "MERGE_OUTCOME=merged"
|
|
755
|
+
gate_reset; cm MEMBER User "$QG_DEG"; cm MEMBER User "$QG_OK"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
756
|
+
rc_is "degraded: a newer clean gate clears it" "$RC" 0
|
|
757
|
+
gate_reset; cm MEMBER User "$QG_OK"; cm MEMBER User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
758
|
+
rc_is "degraded: a newer degraded gate wins" "$RC" 3
|
|
759
|
+
gate_reset; cm NONE User "$QG_DEG"; flush "$FIX/comments-42.json"; mg "$GATE"
|
|
760
|
+
rc_is "degraded: untrusted DEGRADED marker cannot block" "$RC" 0
|
|
761
|
+
gate_reset; mg "$GATE"
|
|
762
|
+
rc_is "degraded: no gate comments is unaffected" "$RC" 0
|
|
763
|
+
gate_reset; rm -f "$FIX/comments.json"; mg "$GATE"
|
|
764
|
+
rc_is "degraded: unreadable comments fail closed" "$RC" 3; not_merged "degraded: unreadable"
|
|
765
|
+
|
|
605
766
|
# --- --issue requirement ---
|
|
606
767
|
gate_reset; mkctx 1 FULL "" ""; mg "$GATE"
|
|
607
768
|
rc_is "issue: missing" "$RC" 3; has "issue: missing" "blocker: auto-merge requires --issue"; posted_has "issue: missing" "no --issue was given"
|
|
@@ -657,6 +818,13 @@ gate_reset; mg "$GATE" --verdict "CHANGES REQUESTED" --base-conflict true --cali
|
|
|
657
818
|
rc_is "route: base conflict plus calibration" "$RC" 3; logged "route: calibration reason" issue-bodies.log "calibration threshold: overconfident cell"; logged "route: calibration needs-human" edits.log "needs-human"
|
|
658
819
|
gate_reset; mg "$GATE" --has-purpose-regression true
|
|
659
820
|
rc_is "route: purpose regression with APPROVED" "$RC" 3; logged "route: purpose reason" issue-bodies.log "purpose regression detected by Phase 7A"; logged "route: purpose needs-human" edits.log "needs-human"; not_merged "route: purpose"
|
|
821
|
+
for payload in "it's cal" "\$(touch $FIX/SENTINEL)" "\`touch $FIX/SENTINEL\`" "'; touch $FIX/SENTINEL; '"; do
|
|
822
|
+
gate_reset; rm -f "$FIX/SENTINEL"; printf '%s' "$payload" > "$FIX/cn.txt"
|
|
823
|
+
mg "$GATE" --calibration-needs-human true --calibration-note-file "$FIX/cn.txt"
|
|
824
|
+
rc_is "calibration note file: gate blocks" "$RC" 3
|
|
825
|
+
if [ -e "$FIX/SENTINEL" ]; then bad "calibration note file: payload executed ($payload)"; else ok; fi
|
|
826
|
+
logged "calibration note file: rendered as data" issue-bodies.log "calibration threshold: $payload"
|
|
827
|
+
done
|
|
660
828
|
gate_reset; mg "$GATE" --calibration-needs-human true --calibration-note "note"
|
|
661
829
|
rc_is "route: calibration alone blocks an APPROVED verdict" "$RC" 3; not_merged "route: calibration alone"
|
|
662
830
|
gate_reset; mg "$GATE" --trust-needs-human true
|
|
@@ -742,6 +910,30 @@ has "7A: blocked log" "PURPOSE REGRESSION GATE: BLOCKED"; has "7A: escalation" "
|
|
|
742
910
|
reset; MH=MERGEABLE; MHS=CLEAN; vd --is-milestone true --has-purpose-regression false; has "7A: passed log" "PURPOSE REGRESSION GATE: PASSED"
|
|
743
911
|
reset; MH=MERGEABLE; MHS=CLEAN; vd --has-purpose-regression true
|
|
744
912
|
case "$OUT" in *"PURPOSE REGRESSION GATE"*) bad "7A: non-milestone PRs print no gate log" ;; *) ok ;; esac
|
|
913
|
+
# free text arrives through *-file flags and is data, never code (injection payload classes: apostrophe, command substitution, backtick, quote breakout, newline)
|
|
914
|
+
SENT="$FIX/SENTINEL"
|
|
915
|
+
for payload in "it's a F-1" "\$(touch $SENT)" "\`touch $SENT\`" "'; touch $SENT; '" "\"; touch $SENT; \"" "F-1
|
|
916
|
+
touch $SENT"; do
|
|
917
|
+
reset; rm -f "$SENT"; MH=MERGEABLE; MHS=CLEAN
|
|
918
|
+
printf '%s' "$payload" > "$FIX/pf.txt"; printf '%s' "$payload" > "$FIX/ag.txt"
|
|
919
|
+
vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt" --agents-file "$FIX/ag.txt"
|
|
920
|
+
rc_is "payload file: verdict runs" "$RC" 0
|
|
921
|
+
if [ -e "$SENT" ]; then bad "payload file: payload executed ($payload)"; else ok; fi
|
|
922
|
+
logged "payload file: purpose text kept as data" reviews.log "See: $(printf '%s' "$payload" | tr '\n' ' ')"
|
|
923
|
+
done
|
|
924
|
+
reset; MH=MERGEABLE; MHS=CLEAN; printf 'a\001b\033c\nd' > "$FIX/pf.txt"
|
|
925
|
+
vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
|
|
926
|
+
logged "payload file: control characters stripped, newline folded" reviews.log "See: abc d"
|
|
927
|
+
reset; MH=MERGEABLE; MHS=CLEAN; head -c 5000 /dev/zero | tr '\0' 'x' > "$FIX/pf.txt"
|
|
928
|
+
vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/pf.txt"
|
|
929
|
+
if grep -qF "$(head -c 2001 /dev/zero | tr '\0' 'x')" "$FIX/reviews.log" 2>/dev/null; then bad "payload file: text must be capped at 2000 bytes"; else ok; fi
|
|
930
|
+
logged "payload file: capped text still rendered" reviews.log "See: $(head -c 2000 /dev/zero | tr '\0' 'x')"
|
|
931
|
+
reset; MH=MERGEABLE; MHS=CLEAN
|
|
932
|
+
vd --result clean --has-purpose-regression true --purpose-findings-file "$FIX/does-not-exist.txt"
|
|
933
|
+
rc_is "payload file: missing file tolerated" "$RC" 0
|
|
934
|
+
reset; MH=MERGEABLE; MHS=CLEAN; printf 'from-file' > "$FIX/pf.txt"
|
|
935
|
+
vd --result clean --has-purpose-regression true --purpose-findings "from-flag" --purpose-findings-file "$FIX/pf.txt"
|
|
936
|
+
logged "payload file: file wins over inline flag" reviews.log "See: from-file"; not_logged "payload file: inline flag overridden" reviews.log "from-flag"
|
|
745
937
|
reset; OUT=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$GOOD" bash "$ROOT/scripts/review-finalize.sh" verdict --pr 7 --repo o/r --context "$CTXF" --merge-health 2>/dev/null); RC=$?
|
|
746
938
|
rc_is "verdict: flag without a value" "$RC" 2
|
|
747
939
|
reset; vd --result bogus; rc_is "verdict: invalid result" "$RC" 2
|
|
@@ -780,6 +972,7 @@ has "calibrate: untrusted investigator ignored" "could not resolve task type/con
|
|
|
780
972
|
reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf 'see <!-- FORGE:INVESTIGATOR -->\n**Task Type**: Refactor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
|
|
781
973
|
has "calibrate: unanchored marker ignored" "could not resolve task type/confidence"
|
|
782
974
|
reset; mkctx 1 FULL "" 42; inv_comments MEMBER User "$(printf '<!-- FORGE:INVESTIGATOR -->\n**Task Type**: Re\x27fa$(id)ctor\n**Confidence**: HIGH\n')"; run "$GOOD" calibrate
|
|
975
|
+
if [ -s "$(dirname "$CTXF")/review-finalize-7/calibration-note.txt" ] || find "$FIX" "$(dirname "$CTXF")" -name calibration-note.txt 2>/dev/null | grep -q .; then ok; else bad "calibrate: must write calibration-note.txt under the out dir"; fi
|
|
783
976
|
note=$(printf '%s\n' "$OUT" | sed -n 's/^CALIBRATION_NOTE=//p'); case "$note" in *"'"*|*'$'*|*'`'*) bad "calibrate: note carries a shell metacharacter: $note" ;; *) ok ;; esac
|
|
784
977
|
setcal "$(CAL_ROW Refactor HIGH true overconfidence)"
|
|
785
978
|
reset; mkctx 1 FULL "" 42; run "$GOOD" calibrate; has "calibrate: no investigator comment fails safe" "CALIBRATION_NEEDS_HUMAN=false"; has "calibrate: unresolved note" "task type or confidence not resolved"
|
|
@@ -829,5 +1022,19 @@ OUT=$(bash "$ROOT/scripts/review-finalize.sh" bogus 2>/dev/null); RC=$?; rc_is "
|
|
|
829
1022
|
OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr x --repo o/r --context "$CTXF" 2>/dev/null); RC=$?; rc_is "non-numeric pr" "$RC" 2
|
|
830
1023
|
OUT=$(bash "$ROOT/scripts/review-finalize.sh" extract --pr 7 --repo o/r 2>/dev/null); RC=$?; rc_is "missing context" "$RC" 2
|
|
831
1024
|
|
|
1025
|
+
# ======================= forge#4037: build.md red-green DEGRADED entry survives the real name splitter =======================
|
|
1026
|
+
RG_LINE=$(grep -F 'RG_DEGRADED_LIST="${RG_DEGRADED_LIST}' "$ROOT/commands/work-on/build.md" | sed -n '1p' | sed 's/^[[:space:]]*\*) //; s/ ;;.*$//')
|
|
1027
|
+
SPLIT_LINE=$(grep -F 'DEG_NAME_LIST=$(printf' "$ROOT/scripts/review-finalize.sh" | sed -n '1p' | sed 's/^[[:space:]]*//')
|
|
1028
|
+
if [ -n "$RG_LINE" ] && [ -n "$SPLIT_LINE" ]; then
|
|
1029
|
+
RG_DEGRADED_LIST=""; ID="AC2"; RG_BASE=UNRUNNABLE; RG_HEAD=RAN
|
|
1030
|
+
eval "$RG_LINE"
|
|
1031
|
+
RG_DEGRADED_LIST="${RG_DEGRADED_LIST}; AC3 — base run cannot execute (base=UNRUNNABLE head=RAN), so the test is unproven"
|
|
1032
|
+
DEG_NAMES="$RG_DEGRADED_LIST"
|
|
1033
|
+
eval "$SPLIT_LINE"
|
|
1034
|
+
[ "$DEG_NAME_LIST" = "$(printf 'AC2\nAC3')" ] && ok || bad "red-green DEGRADED entries split into exactly their check names (got: $DEG_NAME_LIST)"
|
|
1035
|
+
else
|
|
1036
|
+
bad "could not extract the build.md DEGRADED entry or the review-finalize splitter"
|
|
1037
|
+
fi
|
|
1038
|
+
|
|
832
1039
|
echo "review-finalize tests: $pass passed, $fail failed"
|
|
833
1040
|
[ "$fail" -eq 0 ]
|
|
@@ -232,6 +232,19 @@ case "$(p4 DELTA 'security auth billing')" in "PANEL_STATUS: SHORTFALL"*) ok ;;
|
|
|
232
232
|
{ cm "$(body_agent security "$HEAD")" User NONE; cm "$(body_agent auth "$HEAD")" User NONE; } | jq -s '.' > "$FAKE_COMMENTS"
|
|
233
233
|
case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL"*) ok ;; *) bad "untrusted reviewer markers must not satisfy the panel: $(p4 FULL 'security auth')" ;; esac
|
|
234
234
|
|
|
235
|
+
# a verdict without a findings block is undelivered (#3976): not complete, never counted
|
|
236
|
+
{ cm "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\nVerdict: APPROVE, no findings.' "$HEAD")" User MEMBER; cm "$(body_agent auth "$HEAD")" User MEMBER; } | jq -s '.' > "$FAKE_COMMENTS"
|
|
237
|
+
case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=1)"*) ok ;; *) bad "a block-less reviewer comment must not satisfy Phase 4: $(p4 FULL 'security auth')" ;; esac
|
|
238
|
+
# the 3C pending-agent count and the staging 6.75 delivery count carry the same block lookahead as Phase 4
|
|
239
|
+
STAGING="$ROOT/commands/review-pr-staging.md"
|
|
240
|
+
for spec in "$SPEC" "$STAGING"; do
|
|
241
|
+
n=$(grep -F 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' "$spec" | wc -l)
|
|
242
|
+
want=2; [ "$spec" = "$STAGING" ] && want=1
|
|
243
|
+
[ "$n" -eq "$want" ] && ok || bad "$(basename "$spec"): $n delivery counts carry the findings-block lookahead, want $want"
|
|
244
|
+
if grep -E 'count "\^<!-- FORGE:REVIEW-AGENT:\$\{AGENT_DOMAIN\}' "$spec" >/dev/null; then bad "$(basename "$spec"): header-only delivery regex remains"; else ok; fi
|
|
245
|
+
if grep -qE 'scan the trusted (agent comments|reviewer bodies) for unstructured|no unstructured finding' "$spec"; then bad "$(basename "$spec"): free-text finding scan remains"; else ok; fi
|
|
246
|
+
done
|
|
247
|
+
|
|
235
248
|
# ---- 6. The 6A read of the carried / earlier head's findings is scripts/review-finalize.sh extract (covered by scripts/review-finalize.test.sh).
|
|
236
249
|
|
|
237
250
|
echo "review-pr-delta-wiring tests: pass=$PASS fail=$FAILN"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# --pr PR number (numeric, required) --repo owner/name (required)
|
|
14
14
|
# --merge-issue issue the PR closes, from --issue (numeric; non-numeric is ignored)
|
|
15
15
|
# --thorough true restores the full-union roster --out write the context JSON here (default: stdout)
|
|
16
|
-
# --out-dir directory for per-domain diff slices (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
|
|
16
|
+
# --out-dir directory for per-domain diff slices, named slice-<sha7>-<domain>.diff (default: ${FORGE_SCRATCHPAD:-$PWD/.forge-scratch}/review-prepare-<pr>)
|
|
17
17
|
# --checkout-pr `git checkout -f --detach <review sha>` in the CURRENT repo (must be a throwaway review checkout, never the
|
|
18
18
|
# main checkout); the recorded original ref is restored by restore_ref on every exit path
|
|
19
19
|
# --build-gate block (default): an empty trusted typescript.build / python.format on a full-build lane BLOCKs.
|
|
@@ -482,7 +482,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
482
482
|
SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
|
|
483
483
|
# slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
|
|
484
484
|
slice() {
|
|
485
|
-
local dom="$1" dre="$2" fre="$3" body paths df="$OUT_DIR/slice-$1.diff"
|
|
485
|
+
local dom="$1" dre="$2" fre="$3" body paths tmp df="$OUT_DIR/slice-${REVIEW_SHA_SHORT:-unresolved}-$1.diff"
|
|
486
486
|
if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
|
|
487
487
|
else
|
|
488
488
|
body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
|
|
@@ -492,7 +492,9 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
492
492
|
else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
|
|
493
493
|
# 100K-char cap, same as the runner's tool-result cap
|
|
494
494
|
if [ "${#body}" -gt 102400 ]; then body="$(printf '%s' "$body" | head -c 102400)"$'\n'"... [TRUNCATED — diff exceeded 100K chars; focus on the files listed]"; fi
|
|
495
|
-
|
|
495
|
+
# Publish atomically: temp file in $OUT_DIR (same filesystem) then rename, so a concurrent reader sees nothing or the whole file.
|
|
496
|
+
tmp=$(mktemp "$OUT_DIR/.slice-XXXXXX") || { echo "review-prepare: cannot create temp slice in $OUT_DIR" >&2; exit 2; }
|
|
497
|
+
if printf '%s\n' "$body" > "$tmp" && chmod 644 "$tmp"; then mv -f "$tmp" "$df"; else rm -f "$tmp"; echo "review-prepare: cannot write slice $df" >&2; exit 2; fi
|
|
496
498
|
SLICES_JSON=$(printf '%s' "$SLICES_JSON" | jq -c --arg d "$dom" --arg df "$df" --arg p "$paths" '. + {($d): {paths: ($p | split("\n") | map(select(length > 0))), diff_file: $df}}')
|
|
497
499
|
}
|
|
498
500
|
slice security ALL ALL
|
|
@@ -125,8 +125,24 @@ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0
|
|
|
125
125
|
eq "security baseline" .scores.SECURITY 1
|
|
126
126
|
eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
|
|
127
127
|
eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
AUTH_SLICE=$(jqv '.slices.auth.diff_file')
|
|
129
|
+
eq "auth slice name is keyed by head SHA" '.slices.auth.diff_file | endswith("/slice-'"$(printf '%s' "$SHA_B" | cut -c1-7)"'-auth.diff")' true
|
|
130
|
+
if grep -q "auth.py" "$AUTH_SLICE" 2>/dev/null; then ok; else bad "auth slice should hold the auth.py diff"; fi
|
|
131
|
+
if grep -q "billing.py" "$AUTH_SLICE" 2>/dev/null; then bad "auth slice leaked the billing diff"; else ok; fi
|
|
132
|
+
if [ -z "$(ls -A "$WORK"/out/.slice-* 2>/dev/null)" ]; then ok; else bad "temp slice files left behind"; fi
|
|
133
|
+
# SHA isolation: a re-run at a different head SHA must not overwrite the earlier head's slice
|
|
134
|
+
FIRST_SUM=$(cksum < "$AUTH_SLICE")
|
|
135
|
+
setup feat/x staging "services/api/auth.py
|
|
136
|
+
services/api/billing.py" "$DIFF_AUTH_BILL
|
|
137
|
+
+changed-for-head-A" "$SHA_A"
|
|
138
|
+
prep "$ROOT"
|
|
139
|
+
AUTH_SLICE_A=$(jqv '.slices.auth.diff_file')
|
|
140
|
+
if [ -n "$AUTH_SLICE_A" ] && [ "$AUTH_SLICE_A" != "$AUTH_SLICE" ] && [ -f "$AUTH_SLICE" ] && [ -f "$AUTH_SLICE_A" ]; then ok; else bad "different head SHA must produce a distinct slice file ($AUTH_SLICE_A vs $AUTH_SLICE)"; fi
|
|
141
|
+
if [ "$(cksum < "$AUTH_SLICE")" = "$FIRST_SUM" ]; then ok; else bad "earlier head's slice was overwritten by a re-review at another SHA"; fi
|
|
142
|
+
if [ -z "$(ls -A "$WORK"/out/.slice-* 2>/dev/null)" ]; then ok; else bad "temp slice files left behind after re-run"; fi
|
|
143
|
+
setup feat/x staging "services/api/auth.py
|
|
144
|
+
services/api/billing.py" "$DIFF_AUTH_BILL"
|
|
145
|
+
prep "$ROOT"
|
|
130
146
|
eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
|
|
131
147
|
eq "security slice is the full diff" '.slices.security.paths | length' 2
|
|
132
148
|
|
|
@@ -22,6 +22,11 @@ cd "$REPO_ROOT" || exit 1
|
|
|
22
22
|
# Gate scripts emit best-effort telemetry (forge#3834); suites run with it off so stubbed gh calls never buffer into ~/.forge.
|
|
23
23
|
# scripts/telemetry.test.sh sets its own environment.
|
|
24
24
|
export FORGE_TELEMETRY=off
|
|
25
|
+
# Per-run TMPDIR (forge#4016): every suite and child process inherits it and the whole dir is removed at exit,
|
|
26
|
+
# so a temp path a suite forgets to clean never outlives the run (tmpfs quotas fail with EDQUOT otherwise).
|
|
27
|
+
RUN_TMP=$(mktemp -d) || { echo "run-shell-tests: cannot create per-run TMPDIR" >&2; exit 1; }
|
|
28
|
+
trap 'rm -rf "$RUN_TMP"' EXIT
|
|
29
|
+
export TMPDIR="$RUN_TMP"
|
|
25
30
|
|
|
26
31
|
TIMEOUT_SECS="${SHELL_TEST_TIMEOUT:-300}"
|
|
27
32
|
# WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=abc and SHELL_TEST_TIMEOUT=0 both print the error and exit 2
|
|
@@ -55,6 +55,8 @@ for fx in "acceptance_gate phase=review verified=3 failed=0 malformed=1 deferred
|
|
|
55
55
|
check "fixture $ev event" "$(grep '^{' "$TD/posted" | jq -r '.event')" "$ev"
|
|
56
56
|
check "fixture $ev key $key" "$(grep '^{' "$TD/posted" | jq -r --arg k "$key" 'has($k)')" "true"
|
|
57
57
|
done
|
|
58
|
+
reset; FORGE_PR=9 forge_emit finding_disposition severity=MEDIUM scope=introduced disposition=INPR_FIX
|
|
59
|
+
check "finding_disposition carries scope" "$(grep '^{' "$TD/posted" | jq -r '.scope')" "introduced"
|
|
58
60
|
# review-side events go to the PR, others to the issue
|
|
59
61
|
reset; FORGE_PR=9 forge_emit review_panel mode=FULL; grep -q 'issues/9/comments' "$TD/calls" && ok || bad "review_panel targets PR"
|
|
60
62
|
reset; FORGE_PR=9 forge_emit diff_size lines=1; grep -q 'issues/5/comments' "$TD/calls" && ok || bad "diff_size targets issue"
|
|
@@ -47,7 +47,7 @@ eq "bodies with no match prints nothing" "$(printf '[]' | bash "$TC" bodies x)"
|
|
|
47
47
|
SHA=0123456789abcdef0123456789abcdef01234567
|
|
48
48
|
OTHER=fedcba9876543210fedcba9876543210fedcba98
|
|
49
49
|
AGENT_RE="^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
|
|
50
|
-
LOOK_RE="^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
|
|
50
|
+
LOOK_RE="^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
|
|
51
51
|
SYNTH_RE="^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
|
|
52
52
|
NL=$'\n'
|
|
53
53
|
AG_BODY="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- REVIEW-FINDINGS-START -->${NL}<!-- FINDING:real-1 -->${NL}<!-- REVIEW-FINDINGS-END -->"
|
|
@@ -57,6 +57,7 @@ AGJ="[$(c NONE Bot b "$AG_BODY"),$(c NONE User h "$FAKE_AG")]"
|
|
|
57
57
|
eq "agent regex counts only the trusted body" "$(printf '%s' "$AGJ" | bash "$TC" count "$AGENT_RE")" 1
|
|
58
58
|
eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')" "real-1"
|
|
59
59
|
eq "lookahead agent count (REVIEW-FINDINGS-START inside body)" "$(printf '%s' "$AGJ" | bash "$TC" count "$LOOK_RE")" 1
|
|
60
|
+
eq "lookahead rejects START without END (truncated)" "$(printf '%s' "[$(c NONE Bot b "$FAKE_AG")]" | bash "$TC" count "$LOOK_RE")" 0
|
|
60
61
|
eq "lookahead rejects body without REVIEW-FINDINGS-START" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${SHA}")]" | bash "$TC" count "$LOOK_RE")" 0
|
|
61
62
|
eq "agent regex rejects a different head SHA" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${OTHER}")]" | bash "$TC" count "$AGENT_RE")" 0
|
|
62
63
|
eq "agent regex rejects SHA line mid-line" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}note Reviewed-SHA: ${SHA}")]" | bash "$TC" count "$AGENT_RE")" 0
|
|
@@ -52,6 +52,7 @@ mk() {
|
|
|
52
52
|
CONTEXT) items+=('{"body":"<!-- FORGE:CONTEXT -->\n## Context"}') ;;
|
|
53
53
|
ARCH) items+=('{"body":"<!-- FORGE:ARCHITECT -->\n## Plan"}') ;;
|
|
54
54
|
QG_PASS) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n"}') ;;
|
|
55
|
+
QG_DEGRADED) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n## Quality Gate Result\n\n**Result**: PASS\n**Tree**: abc\n**Degraded**: python.test — test run exercised nothing; blast-radius — check did not complete\n"}') ;;
|
|
55
56
|
QG_FAIL) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: FAIL\n"}') ;;
|
|
56
57
|
FP_NOBAND) items+=('{"body":"<!-- FORGE:FAST_PATH -->\nnothing"}') ;;
|
|
57
58
|
FP_*) items+=("{\"body\":\"<!-- FORGE:FAST_PATH -->\\n**COMPLEXITY_BAND**: ${k#FP_}\"}") ;;
|
|
@@ -90,6 +91,7 @@ expect_fail "missing FAST_PATH" "$(mk f INV CONTRACT CONTEXT ARCH QG_PASS)" FAST
|
|
|
90
91
|
expect_fail "FAST_PATH without band value fails" "$(mk g INV CONTRACT FP_NOBAND CONTEXT ARCH QG_PASS)" FAST_PATH
|
|
91
92
|
expect_fail "missing QUALITY_GATE" "$(mk h INV CONTRACT FP_COMPLEX CONTEXT ARCH)" QUALITY_GATE
|
|
92
93
|
expect_fail "QUALITY_GATE FAIL result does not count" "$(mk i INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_FAIL)" QUALITY_GATE
|
|
94
|
+
expect_pass "QUALITY_GATE PASS with a **Degraded**: line still counts (forge#3628)" "$(mk i2 INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_DEGRADED)"
|
|
93
95
|
expect_fail "TRIVIAL still needs CONTRACT" "$(mk j INV FP_TRIVIAL QG_PASS)" CONTRACT
|
|
94
96
|
expect_fail "prose mentions do not satisfy markers" "$(mk k INV CONTRACT FP_COMPLEX PROSE)" CONTEXT
|
|
95
97
|
|
|
@@ -263,6 +265,12 @@ expect_pass "head-tree matches the PASS marker's Tree" "$(ht h1 "$QG_T1")" --hea
|
|
|
263
265
|
expect_fail "PASS recorded for an earlier tree does not satisfy the gate" "$(ht h2 "$QG_T2")" QUALITY_GATE --head-tree "$T1"
|
|
264
266
|
expect_fail "PASS without a Tree line does not satisfy a bound gate" "$(ht h3 "$QG_NOTREE")" QUALITY_GATE --head-tree "$T1"
|
|
265
267
|
expect_pass "a later PASS for the head tree wins over an earlier-tree PASS" "$(ht h4 "$QG_T2" "$QG_T1")" --head-tree "$T1"
|
|
268
|
+
# Post-merge re-gate (#3983): T1 = pre-sync build tree, T2 = merged tree after the base sync
|
|
269
|
+
expect_fail "stale pre-merge PASS does not satisfy the merged head" "$(ht m1 "$QG_T1")" QUALITY_GATE --head-tree "$T2"
|
|
270
|
+
expect_pass "post-merge re-gate marker satisfies the merged head" "$(ht m2 "$QG_T1" "$QG_T2")" --head-tree "$T2"
|
|
271
|
+
expect_pass "pre-merge PASS still satisfies the pre-merge tree" "$(ht m3 "$QG_T1" "$QG_T2")" --head-tree "$T1"
|
|
272
|
+
QG_T2_FAIL=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: FAIL\n**Tree**: '"$T2"
|
|
273
|
+
expect_fail "a FAIL re-gate on the merged tree is not a PASS" "$(ht m4 "$QG_T2_FAIL")" QUALITY_GATE --head-tree "$T2"
|
|
266
274
|
expect_pass "no --head-tree keeps the unbound behaviour" "$(ht h5 "$QG_NOTREE")"
|
|
267
275
|
expect_pass "uppercase head-tree is normalised" "$(ht h6 "$QG_T1")" --head-tree "$(echo "$T1" | tr a-f A-F)"
|
|
268
276
|
expect_pass "docs-only still waives the bound gate" "$(ht h7)" --docs-only --head-tree "$T1"
|
package/scripts/wait-ci-green.sh
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
# 2 ERROR bad usage, gh unreadable for too long, the PR head moved during the wait, or
|
|
19
19
|
# FORGE_CI_REQUIRE_CHECKS=1 and no checks were reported
|
|
20
20
|
# 3 TIMEOUT checks still pending when the timeout expired (listed)
|
|
21
|
+
# 4 RATE_LIMITED the window ended while GitHub only answered with rate-limit (403/429,
|
|
22
|
+
# x-ratelimit-remaining: 0, secondary-limit text) or transient 5xx/network errors. Retryable,
|
|
23
|
+
# never a CI verdict: callers treat it like TIMEOUT (wait and re-run), and it is never a merge.
|
|
21
24
|
#
|
|
22
25
|
# Every result also prints "CI_GATE_HEAD: <sha>" — the exact PR head the decision applies to. Callers
|
|
23
26
|
# MUST merge with `--match-head-commit <that sha>` so a push after the gate cannot land unchecked.
|
|
@@ -26,7 +29,10 @@
|
|
|
26
29
|
# caller re-runs the gate, it does not raise the timeout), FORGE_CI_INTERVAL (default 20),
|
|
27
30
|
# FORGE_CI_NO_CHECKS_GRACE (default 120), FORGE_CI_SETTLE (default 45 — never PASS earlier than
|
|
28
31
|
# this after the wait starts, so workflows that register late are not missed),
|
|
29
|
-
# FORGE_CI_REQUIRE_CHECKS (default 0)
|
|
32
|
+
# FORGE_CI_REQUIRE_CHECKS (default 0), FORGE_CI_JITTER_PCT (default 20 — each sleep varies by +/- this
|
|
33
|
+
# percent so many waiting lanes do not poll in lockstep).
|
|
34
|
+
# Rate-limit and transient gh failures are retried with a growing, jittered sleep and never count toward
|
|
35
|
+
# the 5-error ERROR limit.
|
|
30
36
|
# "No checks" passes only when GitHub Actions also reports no workflow runs for the head commit;
|
|
31
37
|
# if runs exist but their checks have not appeared yet, the gate keeps waiting.
|
|
32
38
|
set -uo pipefail
|
|
@@ -44,7 +50,7 @@ while [ $# -gt 0 ]; do
|
|
|
44
50
|
-R*) REPO="${1#-R}"; REPO="${REPO# }"; shift ;;
|
|
45
51
|
--timeout) [ $# -ge 2 ] || usage; TIMEOUT="$2"; shift 2 ;;
|
|
46
52
|
--interval) [ $# -ge 2 ] || usage; INTERVAL="$2"; shift 2 ;;
|
|
47
|
-
-h|--help) sed -n '5,
|
|
53
|
+
-h|--help) sed -n '5,32p' "$0"; exit 0 ;;
|
|
48
54
|
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
|
|
49
55
|
esac
|
|
50
56
|
done
|
|
@@ -52,17 +58,50 @@ case "$PR" in ''|*[!0-9]*) usage ;; esac
|
|
|
52
58
|
case "$REPO" in */*) ;; *) usage ;; esac
|
|
53
59
|
for v in "$TIMEOUT" "$INTERVAL" "$GRACE" "$SETTLE"; do case "$v" in ''|*[!0-9]*) usage ;; esac; done
|
|
54
60
|
[ "$INTERVAL" -ge 1 ] || INTERVAL=1
|
|
61
|
+
JITTER_PCT="${FORGE_CI_JITTER_PCT:-20}"; case "$JITTER_PCT" in ''|*[!0-9]*) JITTER_PCT=20 ;; esac
|
|
62
|
+
[ "$JITTER_PCT" -le 90 ] || JITTER_PCT=90
|
|
63
|
+
# jitter <seconds> -> seconds varied by +/- JITTER_PCT percent (never below 1).
|
|
64
|
+
jitter() { awk -v s="$1" -v p="$JITTER_PCT" -v r="$RANDOM" 'BEGIN{ v=s*(1-p/100+2*(p/100)*(r/32767)); if (v<1) v=1; printf "%.2f", v }'; }
|
|
65
|
+
|
|
66
|
+
# classify_gh <text> -> prints one class, or nothing when the text is unrecognised:
|
|
67
|
+
# limited real rate-limit evidence (x-ratelimit-remaining: 0, HTTP 429, "API rate limit exceeded" or GraphQL "API rate limit already exceeded", secondary rate limit,
|
|
68
|
+
# abuse detection, too many requests): a wait, bounded by the whole window
|
|
69
|
+
# auth a bare 401/403 (bad credentials, missing scope): permanent, never waited on
|
|
70
|
+
# transient 5xx / gateway / network failures: a small bounded retry (FORGE_CI_TRANSIENT_RETRIES), never the long wait
|
|
71
|
+
classify_gh() {
|
|
72
|
+
if printf '%s' "$1" | grep -Eqi 'api rate limit (already )?exceeded|secondary (rate )?limit|abuse detection|x-ratelimit-remaining: *0|HTTP 429|status 429|too many requests'; then echo limited
|
|
73
|
+
elif printf '%s' "$1" | grep -Eqi 'HTTP (401|403)|status (401|403)|bad credentials|requires authentication'; then echo auth
|
|
74
|
+
elif printf '%s' "$1" | grep -Eqi 'HTTP 5[0-9][0-9]|status 5[0-9][0-9]|bad gateway|service unavailable|gateway time-?out|timed? ?out|i/o timeout|connection (reset|refused)|could not resolve host|unexpected EOF|network is unreachable|TLS handshake'; then echo transient
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
55
77
|
|
|
56
78
|
emit() {
|
|
57
|
-
case "$1" in PASS) _rc=0 ;; FAIL) _rc=1 ;; TIMEOUT) _rc=3 ;; *) _rc=2 ;; esac
|
|
79
|
+
case "$1" in PASS) _rc=0 ;; FAIL) _rc=1 ;; TIMEOUT) _rc=3 ;; RATE_LIMITED) _rc=4 ;; *) _rc=2 ;; esac
|
|
58
80
|
FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit ci_gate result="$1" rc="$_rc" checks="${total:-0}"
|
|
59
81
|
echo "CI_GATE: $1"; echo "CI_GATE_HEAD: ${START_SHA:-unknown}"
|
|
60
82
|
}
|
|
61
83
|
head_sha() { gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null; }
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
# forge#4037: the initial head read goes through the same classification as the checks poll: a rate limit is a wait
|
|
85
|
+
# (rc 4 once the window is spent), a bare 401/403 is a fast permanent error, anything else a small bounded retry.
|
|
86
|
+
START_SHA=""; _hstart=$(date +%s); _hlim=0; _hfail=0; _herr="$(mktemp)"; trap 'rm -f "$_herr"' EXIT
|
|
87
|
+
while :; do
|
|
88
|
+
START_SHA="$(gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>"$_herr")" && [ -n "$START_SHA" ] && break
|
|
89
|
+
START_SHA=""; _hc="$(classify_gh "$(cat "$_herr" 2>/dev/null)")"
|
|
90
|
+
case "$_hc" in
|
|
91
|
+
auth) emit ERROR; echo "cannot read PR #$PR head in $REPO (HTTP 401/403 without rate-limit evidence)"; exit 2 ;;
|
|
92
|
+
limited)
|
|
93
|
+
_hlim=$((_hlim + 1))
|
|
94
|
+
if [ $(( $(date +%s) - _hstart )) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window reading the PR head; retry later"; exit 4; fi
|
|
95
|
+
_m=$((_hlim > 4 ? 4 : _hlim)); sleep "$(jitter $((INTERVAL * (1 << (_m - 1)))))" ;;
|
|
96
|
+
*)
|
|
97
|
+
_hfail=$((_hfail + 1))
|
|
98
|
+
if [ "$_hfail" -gt 2 ]; then emit ERROR; echo "cannot read PR #$PR head in $REPO"; exit 2; fi
|
|
99
|
+
sleep "$(jitter "$INTERVAL")" ;;
|
|
100
|
+
esac
|
|
101
|
+
done
|
|
64
102
|
|
|
65
|
-
start=$(date +%s); errors=0
|
|
103
|
+
start=$(date +%s); errors=0; limited=0; transient=0
|
|
104
|
+
TRANSIENT_MAX="${FORGE_CI_TRANSIENT_RETRIES:-4}"; case "$TRANSIENT_MAX" in ''|*[!0-9]*) TRANSIENT_MAX=4 ;; esac
|
|
66
105
|
while :; do
|
|
67
106
|
now=$(date +%s); elapsed=$((now - start))
|
|
68
107
|
if ! JSON="$(gh pr checks "$PR" -R "$REPO" --json name,bucket 2>/dev/null)" || ! printf '%s' "$JSON" | jq -e 'type=="array"' >/dev/null 2>&1; then
|
|
@@ -72,14 +111,27 @@ while :; do
|
|
|
72
111
|
# does precisely in the "no checks reported" case this branch exists to detect.
|
|
73
112
|
NC_OUT="$(gh pr checks "$PR" -R "$REPO" 2>&1 || true)"
|
|
74
113
|
if printf '%s' "$NC_OUT" | grep -qi 'no checks reported'; then JSON='[]'
|
|
114
|
+
elif GH_CLASS="$(classify_gh "$NC_OUT")"; [ "$GH_CLASS" = auth ]; then
|
|
115
|
+
# A bare 401/403 is an auth fault that never clears: fail fast instead of waiting out the window (forge#4037).
|
|
116
|
+
emit ERROR; echo "gh pr checks refused (HTTP 401/403 without rate-limit evidence); fix the token or its scopes"; exit 2
|
|
117
|
+
elif [ "$GH_CLASS" = transient ]; then
|
|
118
|
+
# Network timeout / 5xx: a small bounded retry, never the long rate-limit wait.
|
|
119
|
+
transient=$((transient + 1))
|
|
120
|
+
if [ "$transient" -gt "$TRANSIENT_MAX" ]; then emit ERROR; echo "gh pr checks unavailable (transient error) ${transient} times in a row"; exit 2; fi
|
|
121
|
+
sleep "$(jitter "$INTERVAL")"; continue
|
|
122
|
+
elif [ "$GH_CLASS" = limited ]; then
|
|
123
|
+
# Real rate limit: a wait, not a verdict. Retry with a growing jittered sleep; never counts toward ERROR.
|
|
124
|
+
limited=$((limited + 1)); now=$(date +%s)
|
|
125
|
+
if [ $((now - start)) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window (${limited} retries); retry later"; exit 4; fi
|
|
126
|
+
_m=$((limited > 4 ? 4 : limited)); sleep "$(jitter $((INTERVAL * (1 << (_m - 1)))))"; continue
|
|
75
127
|
else
|
|
76
128
|
errors=$((errors + 1))
|
|
77
129
|
if [ "$errors" -ge 5 ]; then emit ERROR; echo "gh pr checks unreadable 5 times in a row"; exit 2; fi
|
|
78
|
-
sleep "$INTERVAL"; continue
|
|
130
|
+
sleep "$(jitter "$INTERVAL")"; continue
|
|
79
131
|
fi
|
|
80
132
|
fi
|
|
81
133
|
fi
|
|
82
|
-
errors=0
|
|
134
|
+
errors=0; limited=0; transient=0
|
|
83
135
|
cur="$(head_sha)"
|
|
84
136
|
if [ -n "$cur" ] && [ "$cur" != "$START_SHA" ]; then emit ERROR; echo "PR head moved during the wait ($START_SHA -> $cur); re-review the new head"; exit 2; fi
|
|
85
137
|
total=$(printf '%s' "$JSON" | jq 'length')
|
|
@@ -101,5 +153,5 @@ while :; do
|
|
|
101
153
|
emit PASS; echo "PR #$PR head ${START_SHA:0:7}: $total checks passed or skipped"; exit 0
|
|
102
154
|
fi
|
|
103
155
|
if [ "$elapsed" -ge "$TIMEOUT" ]; then emit TIMEOUT; echo "PR #$PR: still pending after ${TIMEOUT}s:"; echo "$pending"; exit 3; fi
|
|
104
|
-
sleep "$INTERVAL"
|
|
156
|
+
sleep "$(jitter "$INTERVAL")"
|
|
105
157
|
done
|