forgedock 1.17.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 +37 -2
- package/bin/engine/phases.mjs +207 -106
- package/bin/engine/runlog.mjs +17 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +37 -2
- package/bin/engine.mjs +221 -15
- package/bin/forgedock.mjs +1 -1
- package/bin/hooks/pre-tool-use.mjs +65 -32
- package/bin/runner.mjs +125 -6
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +203 -24
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +264 -15
- package/commands/cleanup.md +10 -3
- package/commands/deploy-pr.md +64 -0
- package/commands/fix-ci.md +20 -6
- package/commands/milestone.md +34 -17
- package/commands/orchestrate/phase-4-execution.md +11 -4
- package/commands/review-pr-staging.md +145 -522
- package/commands/review-pr.md +496 -3124
- package/commands/test-gate.md +25 -10
- package/commands/work-on/build/architect.md +66 -1
- package/commands/work-on/build/implement.md +21 -1
- package/commands/work-on/build/validate.md +322 -28
- package/commands/work-on/build.md +173 -95
- package/commands/work-on/close.md +50 -15
- package/commands/work-on/decompose.md +72 -34
- package/commands/work-on/investigate.md +16 -12
- package/commands/work-on/remediate.md +58 -25
- package/commands/work-on/review.md +36 -21
- package/commands/work-on.md +1 -2
- package/docs/CONFIG.md +132 -26
- package/docs/OUTCOME-CODES.md +12 -3
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +13 -5
- package/docs/TELEMETRY.md +7 -4
- package/docs/WORK-ON-RUNTIME.md +24 -0
- package/docs/spec/forge-protocol-v1.md +2 -2
- package/package.json +2 -2
- package/packages/protocol/src/phases.js +5 -5
- 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.sh +1 -1
- package/scripts/cap-findings.test.sh +3 -1
- 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-bash.sh +5 -1
- package/scripts/check-spec-bash.test.sh +20 -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 +71 -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/diff-size.sh +93 -4
- package/scripts/diff-size.test.sh +59 -2
- 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 +14 -12
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/publish-staging-sync.test.sh +48 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/resolve-deploy-gate.sh +32 -6
- package/scripts/resolve-deploy-gate.test.sh +42 -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 +1579 -0
- package/scripts/review-finalize.test.sh +1040 -0
- package/scripts/review-pr-delta-wiring.test.sh +32 -72
- package/scripts/review-pr-domain-map.test.sh +18 -13
- package/scripts/review-pr-gate2.test.sh +9 -65
- package/scripts/review-pr-staging-scope.test.sh +14 -98
- package/scripts/review-prepare.sh +53 -15
- package/scripts/review-prepare.test.sh +37 -3
- package/scripts/review-roster.sh +16 -2
- package/scripts/review-roster.test.sh +22 -0
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +11 -3
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +44 -6
- package/scripts/with-run-tmpdir.sh +20 -0
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
# the PR checkout, a SPEC_CLI persona file, forge.yaml paths.root) as data.
|
|
10
10
|
#
|
|
11
11
|
# Usage: review-prepare.sh --pr N --repo owner/name [--merge-issue N] [--thorough true|false] [--out FILE] [--out-dir DIR]
|
|
12
|
-
# [--checkout-pr]
|
|
12
|
+
# [--checkout-pr] [--no-delta] [--build-gate block|advisory]
|
|
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
|
+
# --build-gate block (default): an empty trusted typescript.build / python.format on a full-build lane BLOCKs.
|
|
20
|
+
# advisory: record it as a SKIPPED Phase 2 finding instead (review-pr-staging: the deploy review only ever
|
|
21
|
+
# reported "SKIPPED — typescript.build not configured", and blocking it would stop every deploy of a repo
|
|
22
|
+
# with plain .js files and no build command, ForgeDock itself included).
|
|
23
|
+
# --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
|
|
24
|
+
# whole bundle), without widening the roster to the full union the way --thorough does
|
|
19
25
|
# Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
|
|
20
26
|
# REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5).
|
|
21
27
|
# Exit: 0 context emitted (status OK) | 3 context emitted with status BLOCKED (the caller must stop, never skip) | 2 usage error
|
|
@@ -52,7 +58,7 @@
|
|
|
52
58
|
|
|
53
59
|
set -u
|
|
54
60
|
|
|
55
|
-
PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"
|
|
61
|
+
PR_NUMBER=""; REPO=""; MERGE_ISSUE=""; THOROUGH="false"; OUT=""; OUT_DIR=""; CHECKOUT_PR="false"; NO_DELTA="false"; BUILD_GATE="block"
|
|
56
62
|
while [ $# -gt 0 ]; do
|
|
57
63
|
case "$1" in
|
|
58
64
|
--pr|--repo|--merge-issue|--thorough|--out|--out-dir)
|
|
@@ -66,6 +72,8 @@ while [ $# -gt 0 ]; do
|
|
|
66
72
|
--out) OUT="${2:-}"; shift 2 ;;
|
|
67
73
|
--out-dir) OUT_DIR="${2:-}"; shift 2 ;;
|
|
68
74
|
--checkout-pr) CHECKOUT_PR="true"; shift ;;
|
|
75
|
+
--no-delta) NO_DELTA="true"; shift ;;
|
|
76
|
+
--build-gate) case "${2:-}" in block|advisory) BUILD_GATE="$2" ;; *) echo "review-prepare: --build-gate must be block or advisory" >&2; exit 2 ;; esac; shift 2 ;;
|
|
69
77
|
*) echo "review-prepare: unknown argument: $1" >&2; exit 2 ;;
|
|
70
78
|
esac
|
|
71
79
|
done
|
|
@@ -104,7 +112,7 @@ cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
|
|
|
104
112
|
trap cleanup EXIT
|
|
105
113
|
|
|
106
114
|
# --- Phase 1A: fetch ---
|
|
107
|
-
PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
|
|
115
|
+
PR_INFO=$(gh pr view "$PR_NUMBER" -R "$REPO" --json number,title,author,baseRefName,headRefName,headRefOid,isCrossRepository,additions,deletions,changedFiles 2>/dev/null) || PR_INFO=""
|
|
108
116
|
printf '%s' "$PR_INFO" | jq -e . >/dev/null 2>&1 || PR_INFO=""
|
|
109
117
|
if [ -z "$PR_INFO" ]; then block "gh pr view failed for #${PR_NUMBER}"; fi
|
|
110
118
|
pj() { printf '%s' "$PR_INFO" | jq -r "$1 // empty" 2>/dev/null; }
|
|
@@ -118,8 +126,18 @@ LINES=$((ADDITIONS + DELETIONS))
|
|
|
118
126
|
|
|
119
127
|
FILES=""; DIFF=""
|
|
120
128
|
if [ -z "$BLOCKERS" ]; then
|
|
121
|
-
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) ||
|
|
122
|
-
DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) ||
|
|
129
|
+
FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || FILES="__FAILED__"
|
|
130
|
+
[ "$FILES" = "__FAILED__" ] || DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || FILES="__FAILED__"
|
|
131
|
+
if [ "$FILES" = "__FAILED__" ]; then
|
|
132
|
+
# A staging bundle can exceed the API's diff size limit: read it from the long-lived branches instead (--no-delta only; a per-PR review blocks).
|
|
133
|
+
FILES=""; DIFF=""
|
|
134
|
+
# Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
|
|
135
|
+
if [ "$NO_DELTA" = "true" ] && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
|
|
136
|
+
&& git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
|
|
137
|
+
FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
|
|
138
|
+
fi
|
|
139
|
+
[ -n "$FILES" ] || block "gh pr diff failed"
|
|
140
|
+
fi
|
|
123
141
|
[ -n "$FILES" ] || [ -n "$BLOCKERS" ] || block "PR #${PR_NUMBER} has an empty file list"
|
|
124
142
|
fi
|
|
125
143
|
|
|
@@ -201,8 +219,13 @@ if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
|
|
|
201
219
|
if [ "$CONFIG_READABLE" != "true" ]; then
|
|
202
220
|
BUILD_CONFIG_STATUS="blocked"; block "full-build lane but no readable trusted forge.yaml (checked FORGE_CONFIG, the main checkout and the git top-level)"
|
|
203
221
|
else
|
|
204
|
-
if [
|
|
205
|
-
|
|
222
|
+
if [ "$BUILD_GATE" = "advisory" ]; then
|
|
223
|
+
if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="skipped"; p2 "1B: SKIPPED — typescript.build not configured in verification.commands (build not run)"; fi
|
|
224
|
+
if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="skipped"; p2 "1A: SKIPPED — python.format not configured in verification.commands"; fi
|
|
225
|
+
else
|
|
226
|
+
if [ -n "$HAS_TS" ] && [ -z "$TS_BUILD" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: typescript.build is empty in verification.commands on a full-build lane (typecheck alone misses prerender failures)"; fi
|
|
227
|
+
if [ -n "$HAS_PY" ] && [ -z "$PY_FORMAT" ]; then BUILD_CONFIG_STATUS="blocked"; block "BLOCKED: python.format is empty in verification.commands on a full-build lane"; fi
|
|
228
|
+
fi
|
|
206
229
|
fi
|
|
207
230
|
fi
|
|
208
231
|
# 2D env vars, 2E secrets (CRITICAL), 2F SQL, over the diff text
|
|
@@ -346,6 +369,13 @@ CONTRACT_RISK_FLAGS="${CONTRACT_RISK_FLAGS# }"
|
|
|
346
369
|
# --- Phase 3B step 4: roster (review-roster.sh; unresolved or failing means the full union) ---
|
|
347
370
|
agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
|
|
348
371
|
FIX_PR=false; SRC_SEV=""; SRC_DOM=""
|
|
372
|
+
# Review depth (forge#3862): latest TRUSTED FORGE:DIFF_SIZE on the merge issue; unreadable or absent stays STANDARD (extended only adds reviewers).
|
|
373
|
+
REVIEW_DEPTH=STANDARD; REVIEW_WEIGHT=""
|
|
374
|
+
if [ -n "$MERGE_ISSUE" ]; then
|
|
375
|
+
_ds=$(gh api --paginate "repos/$REPO/issues/$MERGE_ISSUE/comments" --jq '[.[] | select(.body | startswith("<!-- FORGE:DIFF_SIZE")) | select((.author_association | IN("OWNER","MEMBER","COLLABORATOR")) or (.user.type == "Bot"))] | last | .body // ""' 2>/dev/null) || _ds=""
|
|
376
|
+
printf '%s\n' "$_ds" | grep -qE '^review_depth: *EXTENDED[[:space:]]*$' && REVIEW_DEPTH=EXTENDED
|
|
377
|
+
REVIEW_WEIGHT=$(printf '%s\n' "$_ds" | sed -n 's/^review_weight: *\([0-9][0-9]*\).*/\1/p' | head -n 1)
|
|
378
|
+
fi
|
|
349
379
|
if [ -n "$MERGE_ISSUE" ]; then
|
|
350
380
|
gh issue view "$MERGE_ISSUE" -R "$REPO" --json labels --jq '[.labels[].name] | any(. == "review-finding")' 2>/dev/null | grep -qx true && FIX_PR=true
|
|
351
381
|
if [ "$FIX_PR" = "true" ]; then
|
|
@@ -361,7 +391,7 @@ DBC=false; printf '%s\n' "$DIFF" | grep -qE "docker-compose.*postgres|docker-com
|
|
|
361
391
|
ROSTER_OUT=""
|
|
362
392
|
[ -n "$ROSTER_SCRIPT" ] && ROSTER_OUT=$(printf '%s\n' "$FILES" | bash "$ROSTER_SCRIPT" --files-file - --scores "$SC" --changed-lines "$LINES" --changed-files "$NFILES" \
|
|
363
393
|
--fix-pr "$FIX_PR" --source-severity "$SRC_SEV" --source-domain "$SRC_DOM" --contract-flags "$CONTRACT_RISK_FLAGS" --churn "$CHURN_ESCALATION" --danger-zone "$DANGER" \
|
|
364
|
-
--thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" 2>/dev/null)
|
|
394
|
+
--thorough "$THOROUGH" --milestone "$IS_MILESTONE_TO_STAGING" --scraping-configured "$SCRAPING_ON" --database-container "$DBC" --spec-cli "$SPEC_CLI" --review-depth "$REVIEW_DEPTH" 2>/dev/null)
|
|
365
395
|
SELECTED_AGENTS=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^SELECTED_AGENTS=//p')
|
|
366
396
|
ROSTER_TIER=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^TIER=//p')
|
|
367
397
|
ROSTER_REASON=$(printf '%s\n' "$ROSTER_OUT" | sed -n 's/^REASON=//p')
|
|
@@ -405,7 +435,7 @@ delta_roster() {
|
|
|
405
435
|
printf '%s' "$out"
|
|
406
436
|
}
|
|
407
437
|
REVIEW_MODE="FULL"; DELTA_FROM=""; CARRIED_FROM=""; FULL_ROUNDS=0; DELTA_FILES=""
|
|
408
|
-
if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ]; then
|
|
438
|
+
if [ "$THOROUGH" != "true" ] && [ "$IS_MILESTONE_TO_STAGING" != "true" ] && [ "$NO_DELTA" != "true" ]; then
|
|
409
439
|
read -r RD_KIND RD_ARG RD_ROUNDS <<< "$(review_delta_mode)"
|
|
410
440
|
FULL_ROUNDS="${RD_ROUNDS:-0}"
|
|
411
441
|
case "$RD_KIND" in
|
|
@@ -426,7 +456,13 @@ IDX_AUTH=""; IDX_BILLING=""; IDX_DATABASE=""; IDX_API=""; IDX_FRONTEND=""; IDX_I
|
|
|
426
456
|
SLICES_JSON="{}"
|
|
427
457
|
if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
428
458
|
review_template_resolve "$REPO_PATH"
|
|
429
|
-
[ "$TEMPLATE_SOURCE" = "none" ]
|
|
459
|
+
if [ "$TEMPLATE_SOURCE" = "none" ]; then
|
|
460
|
+
block "no review-pr-agents template source resolved: run \`npx forgedock update\`; NEVER review inline"
|
|
461
|
+
# forge#3936: keep the setup-error trace and the needs-human alert the old spec bash posted before returning BLOCKED.
|
|
462
|
+
_msg="Review of PR #${PR_NUMBER} could not start: no review-pr-agents template source resolved (broken install). Run \`npx forgedock update\`, then re-run the review. Reviews are never done inline."
|
|
463
|
+
gh pr comment "$PR_NUMBER" -R "$REPO" --body "$_msg" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
|
|
464
|
+
[ -z "$MERGE_ISSUE" ] || gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label needs-human >/dev/null 2>&1 || true # allowlist:check-command-side-effects
|
|
465
|
+
fi
|
|
430
466
|
if [ -n "$CODE_INDEX_SCRIPT" ]; then bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1 || true; fi
|
|
431
467
|
build_index_slice() {
|
|
432
468
|
local domain="${1}" files
|
|
@@ -446,7 +482,7 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
446
482
|
SLICE_FILES_SRC="$FILES"; [ "$REVIEW_MODE" = "DELTA" ] && SLICE_FILES_SRC="$DELTA_FILES"
|
|
447
483
|
# slice <domain> <diff-header regex|ALL> <file-path regex|ALL>
|
|
448
484
|
slice() {
|
|
449
|
-
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"
|
|
450
486
|
if [ "$dre" = "ALL" ]; then body="$FULL_DIFF"
|
|
451
487
|
else
|
|
452
488
|
body=$(printf '%s\n' "$FULL_DIFF" | awk -v re="$dre" '/^diff --git/ { in_block=0 } $0 ~ re { in_block=1 } in_block { print }')
|
|
@@ -456,7 +492,9 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
|
|
|
456
492
|
else paths=$(printf '%s\n' "$SLICE_FILES_SRC" | grep -iE "$fre" || echo ""); [ -n "$paths" ] || paths="$SLICE_FILES_SRC"; fi
|
|
457
493
|
# 100K-char cap, same as the runner's tool-result cap
|
|
458
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
|
|
459
|
-
|
|
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
|
|
460
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}}')
|
|
461
499
|
}
|
|
462
500
|
slice security ALL ALL
|
|
@@ -487,7 +525,7 @@ CTX=$(jq -n \
|
|
|
487
525
|
--argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
|
|
488
526
|
--argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
|
|
489
527
|
--arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
|
|
490
|
-
--arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
|
|
528
|
+
--arg rdepth "$REVIEW_DEPTH" --arg rweight "$REVIEW_WEIGHT" --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
|
|
491
529
|
--arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --argjson dfiles "$(j_lines "$DELTA_FILES")" \
|
|
492
530
|
--arg pname "$PROJECT_NAME" --arg pctx "$PROJECT_CONTEXT" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
|
|
493
531
|
--arg d_auth "$DOMAIN_CONTEXT_AUTH" --arg d_billing "$DOMAIN_CONTEXT_BILLING" --arg d_conc "$DOMAIN_CONTEXT_CONCURRENCY" --arg d_infra "$DOMAIN_CONTEXT_INFRA" \
|
|
@@ -505,7 +543,7 @@ CTX=$(jq -n \
|
|
|
505
543
|
broken_assumptions: $asm, risk_signals: $risk, churn_context: $churn, churn_escalation: ($churn_esc == "true"), danger_zone: ($danger == "true"),
|
|
506
544
|
scores: {AUTH: $sa, BILLING: $sb, CONCURRENCY: $sc, DATABASE: $sd, INFRA: $si, SECURITY: $ss, SCRAPING: $ssc, FRONTEND: $sf, API: $sap},
|
|
507
545
|
contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
|
|
508
|
-
roster: {tier: $rtier, reason: $rreason, agents: $agents},
|
|
546
|
+
roster: {tier: $rtier, review_depth: $rdepth, review_weight: $rweight, reason: $rreason, agents: $agents},
|
|
509
547
|
delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles},
|
|
510
548
|
config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
|
|
511
549
|
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}},
|
|
@@ -68,7 +68,7 @@ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n
|
|
|
68
68
|
# setup <head> <base> <files> <diff> [sha]
|
|
69
69
|
setup() {
|
|
70
70
|
rm -f "${FIX:?}"/*; printf 'MERGEABLE|CLEAN\n' > "$FIX/merge.seq"; : > "$FIX/body.txt"
|
|
71
|
-
printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" > "$FIX/prinfo.json"
|
|
71
|
+
printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","isCrossRepository":%s,"additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" "${CROSS_REPO:-false}" > "$FIX/prinfo.json"
|
|
72
72
|
printf '%s\n' "$3" > "$FIX/files"; printf '%s\n' "$4" > "$FIX/diff"
|
|
73
73
|
}
|
|
74
74
|
CTX=""; RC=0
|
|
@@ -94,6 +94,8 @@ prep "$ROOT"
|
|
|
94
94
|
rc_is "happy path" 0
|
|
95
95
|
eq "schema" .schema_version 1; eq "status" .status OK; eq "pr number" .pr.number 7; eq "review sha" .pr.review_sha "$SHA_B"
|
|
96
96
|
eq "sha7" .pr.review_sha_short "$(printf '%s' "$SHA_B" | cut -c1-7)"
|
|
97
|
+
eq "review depth defaults to STANDARD" .roster.review_depth STANDARD
|
|
98
|
+
eq "review weight key present in context" '.roster | has("review_weight")' true
|
|
97
99
|
eq "small tier" .roster.tier small; eq "small roster" '.roster.agents | join(" ")' security
|
|
98
100
|
eq "lines" '.pr.additions + .pr.deletions' 7
|
|
99
101
|
eq "first-time false" .pr.first_time_contributor false
|
|
@@ -123,8 +125,24 @@ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0
|
|
|
123
125
|
eq "security baseline" .scores.SECURITY 1
|
|
124
126
|
eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
|
|
125
127
|
eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
|
|
126
|
-
|
|
127
|
-
|
|
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"
|
|
128
146
|
eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
|
|
129
147
|
eq "security slice is the full diff" '.slices.security.paths | length' 2
|
|
130
148
|
|
|
@@ -189,6 +207,11 @@ rc_is "empty ts build on a milestone lane" 3; eq "BLOCKED status" .status BLOCKE
|
|
|
189
207
|
eq "blocker names typescript.build" '.blockers | join(" ") | test("typescript.build")' true
|
|
190
208
|
eq "config status blocked" .phase2.build_config.status blocked; eq "lane flag" .lane.requires_full_build true
|
|
191
209
|
prep "$ROOT"; rc_is "configured ts build passes" 0; eq "status OK" .status OK
|
|
210
|
+
# --build-gate advisory (review-pr-staging): an unconfigured build is a SKIPPED Phase 2 finding, never a block (#3952 QA-1)
|
|
211
|
+
CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT" --build-gate advisory
|
|
212
|
+
rc_is "advisory build gate does not block" 0; eq "advisory status OK" .status OK
|
|
213
|
+
eq "advisory config status skipped" .phase2.build_config.status skipped
|
|
214
|
+
eq "advisory records SKIPPED finding" '.phase2.findings | join(" ") | test("SKIPPED — typescript.build")' true
|
|
192
215
|
PY_DIFF='diff --git a/app/x.py b/app/x.py
|
|
193
216
|
+x'
|
|
194
217
|
printf 'project:\n name: Demo\n' > "$WORK/empty.yaml"
|
|
@@ -253,6 +276,17 @@ if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok
|
|
|
253
276
|
# --- 10. fail closed on gh and bad arguments ---
|
|
254
277
|
setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
255
278
|
prep "$ROOT"; rc_is "gh pr diff failure" 3; eq "blocked on gh failure" .status BLOCKED
|
|
279
|
+
# a staging bundle review (--no-delta) reads an API-oversized diff from the long-lived branches; a per-PR review still blocks
|
|
280
|
+
setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
281
|
+
prep "$ROOT" --no-delta; rc_is "--no-delta falls back to git when the API diff fails" 0; eq "fallback status" .status OK
|
|
282
|
+
eq "fallback file list comes from git" '.full_files | index("services/api/auth.py") != null' true; eq "--no-delta mode is FULL" .delta.mode FULL
|
|
283
|
+
# forge#3935: the fallback is pinned to the review SHA and refuses fork PRs
|
|
284
|
+
setup pr-branch main "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"; : > "$FIX/difffail"
|
|
285
|
+
prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a branch tip that is not the review SHA" 3
|
|
286
|
+
CROSS_REPO=true setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
287
|
+
prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a cross-repository PR" 3
|
|
288
|
+
setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
|
|
289
|
+
prep "$ROOT" --no-delta; rc_is "--no-delta with unfetchable branches still fails closed" 3; eq "unfetchable blocked" .status BLOCKED
|
|
256
290
|
setup feat/x staging "README.md" "$DIFF_PLAIN"
|
|
257
291
|
for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
|
|
258
292
|
# shellcheck disable=SC2086
|
package/scripts/review-roster.sh
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
# [--source-severity SEV] [--source-domain DOMAIN] [--contract-flags "HIGH_RISK CONTRACT_AUTH ..."]
|
|
12
12
|
# [--churn true|false] [--danger-zone true|false] [--thorough true|false] [--milestone true|false]
|
|
13
13
|
# [--scraping-configured true|false] [--database-container true|false] [--spec-cli true|false]
|
|
14
|
+
# [--review-depth STANDARD|EXTENDED]
|
|
14
15
|
# Output (stdout): SELECTED_AGENTS=<canonical marker domains, space separated> TIER=<tier> REASON=<text>
|
|
15
16
|
# Exit: 0 always for a parseable call; 2 on an unknown flag (callers fall back to the full union, never a narrower roster).
|
|
16
17
|
#
|
|
@@ -20,6 +21,10 @@
|
|
|
20
21
|
# small <= 60 changed lines, or one file of <= 150 lines: Security only.
|
|
21
22
|
# medium 61-150 lines: Security plus the score-escalation table (no top-2 baseline).
|
|
22
23
|
# standard > 150 lines, or an unknown size (fail toward more agents): top-2 domains by score plus the table.
|
|
24
|
+
# --review-depth EXTENDED (forge#3862: the build's FORGE:DIFF_SIZE review_weight or out-of-plan scope flagged extended
|
|
25
|
+
# review) only ever ADDS reviewers: a small, medium or fix-pr roster is lifted to standard and the standard baseline takes
|
|
26
|
+
# the top-3 domains instead of top-2. It never narrows a roster and never touches the Security floor. Anything but the
|
|
27
|
+
# exact value EXTENDED is STANDARD. An extended roster prints an extra DEPTH=extended line.
|
|
23
28
|
# Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
|
|
24
29
|
# pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
|
|
25
30
|
# files (Auth), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
|
|
@@ -30,7 +35,7 @@ set -u
|
|
|
30
35
|
|
|
31
36
|
S_AUTH=0; S_BILLING=0; S_CONCURRENCY=0; S_DATABASE=0; S_INFRA=0; S_SCRAPING=0; S_FRONTEND=0; S_API=0
|
|
32
37
|
LINES=""; NFILES=""; FILES_FILE=""; FIX_PR=false; SRC_SEV=""; SRC_DOM=""; CFLAGS=""
|
|
33
|
-
CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
|
|
38
|
+
DEPTH=STANDARD; CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
|
|
34
39
|
|
|
35
40
|
num() { case "${1:-}" in ''|*[!0-9]*) printf '0' ;; *) printf '%s' "$1" ;; esac; }
|
|
36
41
|
agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
|
|
@@ -67,6 +72,7 @@ while [ $# -gt 0 ]; do
|
|
|
67
72
|
--scraping-configured) SCRAPING_ON="${2:-false}"; shift 2 ;;
|
|
68
73
|
--database-container) DB_CONTAINER="${2:-false}"; shift 2 ;;
|
|
69
74
|
--spec-cli) SPEC_CLI="${2:-false}"; shift 2 ;;
|
|
75
|
+
--review-depth) [ "${2:-}" = "EXTENDED" ] && DEPTH=EXTENDED; shift 2 ;;
|
|
70
76
|
*) echo "review-roster: unknown argument: $1" >&2; exit 2 ;;
|
|
71
77
|
esac
|
|
72
78
|
done
|
|
@@ -121,6 +127,12 @@ fi
|
|
|
121
127
|
# CONTRACT HIGH_RISK is never narrowed: a small, medium or fix-PR roster is lifted to the standard roster.
|
|
122
128
|
BASE_TIER="$TIER"
|
|
123
129
|
if [ "$HIGH_RISK" = "true" ] && [ "$TIER" != "standard" ]; then TIER=standard; REASON="$REASON; CONTRACT HIGH_RISK lifts $BASE_TIER to standard"; fi
|
|
130
|
+
# Extended review depth (forge#3862) is only ever additive: lift to standard, widen the standard baseline to top-3.
|
|
131
|
+
TOP_N=2
|
|
132
|
+
if [ "$DEPTH" = "EXTENDED" ]; then
|
|
133
|
+
TOP_N=3
|
|
134
|
+
if [ "$TIER" != "standard" ]; then REASON="$REASON; extended review depth lifts $TIER to standard"; TIER=standard; else REASON="$REASON; extended review depth"; fi
|
|
135
|
+
fi
|
|
124
136
|
|
|
125
137
|
TRIG=""
|
|
126
138
|
trig() { case " $TRIG " in *" $1 "*) ;; *) TRIG="$TRIG $1" ;; esac; }
|
|
@@ -133,7 +145,7 @@ case "$TIER" in
|
|
|
133
145
|
if [ -n "$SRC_DOM" ]; then add_agent "$SRC_DOM"; else for d in $(top_domains 1); do add_agent "$d"; done; fi
|
|
134
146
|
REASON="$REASON; source finding ${SRC_SEV} adds its domain" ;;
|
|
135
147
|
esac ;;
|
|
136
|
-
standard) for d in $(top_domains
|
|
148
|
+
standard) for d in $(top_domains "$TOP_N"); do add_agent "$d"; done ;;
|
|
137
149
|
esac
|
|
138
150
|
|
|
139
151
|
# Score escalation table (medium and standard only; diff-text signals, so never in the small or fix-pr tiers)
|
|
@@ -170,3 +182,5 @@ fi
|
|
|
170
182
|
|
|
171
183
|
[ -n "$TRIG" ] && REASON="$REASON; triggers:$TRIG"
|
|
172
184
|
printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
|
|
185
|
+
[ "$DEPTH" = "EXTENDED" ] && printf 'DEPTH=extended\n'
|
|
186
|
+
exit 0
|
|
@@ -159,5 +159,27 @@ hold 3520 4977 9 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engin
|
|
|
159
159
|
hold 3524 156 5 false "spec-cli" "spec-cli" "bin/engine.mjs bin/engine/phases.mjs bin/runner.mjs bin/tests/engine-phases.test.mjs bin/tests/engine.test.mjs"
|
|
160
160
|
hold 3555 137 8 false "security" "" "bin/engine/phases.mjs bin/tests/engine-phases.test.mjs commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/decompose.md commands/work-on/investigate.md packages/protocol/CHANGELOG.md packages/protocol/src/phases.js"
|
|
161
161
|
|
|
162
|
+
# forge#3862: extended review depth (from the build's review_weight / scope flag) only ever adds reviewers
|
|
163
|
+
SC_TOP="AUTH=0 BILLING=1 CONCURRENCY=0 DATABASE=1 INFRA=0 SECURITY=1 SCRAPING=0 FRONTEND=2 API=2"
|
|
164
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1
|
|
165
|
+
expect_tier "no depth flag stays small" small; expect_sel "no depth flag stays security-only" "security"
|
|
166
|
+
case "$out" in *DEPTH=*) bad "standard roster prints no DEPTH line" ;; *) ok ;; esac
|
|
167
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth STANDARD
|
|
168
|
+
expect_tier "STANDARD depth stays small" small
|
|
169
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth extended
|
|
170
|
+
expect_tier "lowercase depth is not EXTENDED" small
|
|
171
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth EXTENDED
|
|
172
|
+
expect_tier "EXTENDED lifts small to standard" standard; expect_has "EXTENDED keeps security" security
|
|
173
|
+
expect_has "EXTENDED small adds top domains" frontend; expect_has "EXTENDED small adds top domains" api
|
|
174
|
+
case "$out" in *DEPTH=extended*) ok ;; *) bad "extended roster prints DEPTH=extended" ;; esac
|
|
175
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6
|
|
176
|
+
expect_not "standard top-2 omits the third domain" database
|
|
177
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6 --review-depth EXTENDED
|
|
178
|
+
expect_has "EXTENDED standard takes a third domain" database; expect_has "EXTENDED standard keeps security" security
|
|
179
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 30 --fix-pr true --review-depth EXTENDED
|
|
180
|
+
expect_tier "EXTENDED lifts fix-pr to standard" standard
|
|
181
|
+
run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --thorough true --review-depth EXTENDED
|
|
182
|
+
expect_tier "full stays full" full
|
|
183
|
+
|
|
162
184
|
echo "review-roster: $pass passed, $fail failed"
|
|
163
185
|
[ "$fail" -eq 0 ]
|
|
@@ -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 -->"
|
|
@@ -55,8 +55,9 @@ FAKE_AG="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!--
|
|
|
55
55
|
FAKE_SYN="<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->${NL}Reviewed-SHA: ${SHA}${NL}"
|
|
56
56
|
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
|
-
eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(
|
|
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
|
|
@@ -74,7 +75,14 @@ STG_FORGED="<!-- FORGE:REVIEW-AGENT:bug-hunter-api -->${NL}Reviewed-SHA: ${SHA}$
|
|
|
74
75
|
STGJ="[$(c NONE Bot b "$STG_TRUE"),$(c NONE Bot b "$STG_TRUE"),$(c NONE User h "$STG_FORGED"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:code-quality -->${NL}Reviewed-SHA: ${OTHER}")]"
|
|
75
76
|
eq "staging per-domain count ignores forged and duplicate-free of untrusted" "$(printf '%s' "$STGJ" | bash "$TC" count "$STG_DOM_RE")" 2
|
|
76
77
|
eq "staging unique trusted current-head domains (hyphen, wrong head excluded)" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' | sort -u | grep -c .)" 2
|
|
77
|
-
eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:(
|
|
78
|
+
eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' | sort -u)" "real-2"
|
|
79
|
+
# Marker titles containing '>' must be extracted in full (the old [^>]+ capture dropped them); two markers on one line still split
|
|
80
|
+
GT_BODY="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- FINDING:SEC-9|CONFIRMED|HIGH|a.sh:3|eval of x -> y reaches shell -->${NL}<!-- FINDING:SEC-10|LIKELY|LOW|b.sh:1|a > b --> <!-- FINDING:SEC-11|LIKELY|LOW|c.sh:2|plain -->"
|
|
81
|
+
GTJ="[$(c NONE Bot b "$GT_BODY")]"
|
|
82
|
+
GT_OUT=$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')
|
|
83
|
+
eq "marker with -> in title extracted in full" "$(printf '%s\n' "$GT_OUT" | grep -c 'eval of x -> y reaches shell')" 1
|
|
84
|
+
eq "markers with > and two on one line all extracted" "$(printf '%s\n' "$GT_OUT" | grep -c .)" 3
|
|
85
|
+
eq "raw marker count equals extracted count" "$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | grep -o '<!-- FINDING:' | wc -l | tr -d ' ')" 3
|
|
78
86
|
eq "staging regex rejects forged-only comment from NONE user" "$(printf '[%s]' "$(c NONE User h "$STG_FORGED")" | bash "$TC" count "$STG_RE")" 0
|
|
79
87
|
|
|
80
88
|
# fail closed
|
|
@@ -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
|