forgedock 1.16.2 → 1.17.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 +92 -0
- package/bin/engine/phases.mjs +86 -26
- package/bin/engine/projector.mjs +48 -1
- package/bin/engine/runlog.mjs +13 -5
- package/bin/engine/telemetry-seam.mjs +62 -0
- package/bin/engine/telemetry-seam.test.mjs +60 -0
- package/bin/engine/telemetry.mjs +148 -0
- package/bin/engine/telemetry.test.mjs +120 -0
- package/bin/engine-cli.mjs +12 -1
- package/bin/engine.mjs +145 -40
- package/bin/forgedock.mjs +7 -1
- package/bin/hooks/interactive-engine.mjs +10 -0
- package/bin/hooks/pre-tool-use.mjs +187 -0
- package/bin/runner.mjs +7 -0
- package/bin/stats/aggregate.mjs +231 -0
- package/bin/stats/cache.mjs +40 -0
- package/bin/stats/github-source.mjs +140 -0
- package/bin/stats/index.mjs +154 -0
- package/bin/stats/local-source.mjs +79 -0
- package/bin/stats/package.json +6 -0
- package/bin/stats/render.mjs +82 -0
- package/bin/stats/stats.test.mjs +224 -0
- package/commands/cleanup.md +83 -2
- package/commands/deploy-info.md +8 -4
- package/commands/orchestrate/phase-1-resolve.md +4 -1
- package/commands/orchestrate/phase-4-execution.md +17 -2
- package/commands/orchestrate/phase-5-cleanup.md +18 -2
- package/commands/pipeline-health/phase-1-context.md +2 -0
- package/commands/pipeline-health/phase-2-metrics.md +12 -5
- package/commands/pipeline-status.md +21 -1
- package/commands/review-pr-agents/protocols.md +1 -0
- package/commands/review-pr-agents.md +10 -2
- package/commands/review-pr-staging.md +102 -25
- package/commands/review-pr.md +119 -103
- package/commands/work-on/build/implement.md +26 -5
- package/commands/work-on/build/validate.md +3 -2
- package/commands/work-on/build.md +95 -64
- package/commands/work-on/close.md +17 -6
- package/commands/work-on/decompose.md +45 -4
- package/commands/work-on/investigate.md +6 -2
- package/commands/work-on/remediate.md +22 -6
- package/commands/work-on/review.md +2 -2
- package/docs/CONFIG.md +33 -0
- package/docs/OUTCOME-CODES.md +48 -0
- package/docs/RELEASE-NOTES.md +12 -0
- package/docs/STATS.md +62 -0
- package/docs/TELEMETRY.md +65 -0
- package/docs/WORK-ON-RUNTIME.md +13 -0
- package/docs/site/command-reference.md +1 -1
- package/docs/spec/label-state-machine.md +1 -1
- package/package.json +3 -2
- package/scripts/builder-resume-match.test.sh +58 -0
- package/scripts/cap-findings.sh +110 -0
- package/scripts/cap-findings.test.sh +130 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-finding.sh +65 -16
- package/scripts/classify-finding.test.sh +43 -1
- package/scripts/classify-grep-exit.sh +20 -0
- package/scripts/classify-grep-exit.test.sh +46 -0
- package/scripts/close-parent-c3.test.sh +54 -0
- package/scripts/cost-report.sh +146 -0
- package/scripts/cost-report.test.sh +80 -0
- package/scripts/diff-size.sh +5 -0
- package/scripts/doctor-pipeline-state.sh +43 -0
- package/scripts/file-deferred-followups.sh +159 -0
- package/scripts/file-deferred-followups.test.sh +106 -0
- package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
- package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
- package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
- package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
- package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
- package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
- package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
- package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
- package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
- package/scripts/forge-root.test.sh +8 -3
- package/scripts/lib/fence-bootstrap.sh +4 -3
- package/scripts/lib/pr-issue-number.sh +18 -0
- package/scripts/lib/review-template-resolve.sh +41 -0
- package/scripts/lib/telemetry.sh +168 -0
- package/scripts/remediate-liveness.sh +42 -6
- package/scripts/review-acceptance-gate.sh +16 -7
- package/scripts/review-acceptance-gate.test.sh +13 -0
- package/scripts/review-pr-domain-map.test.sh +8 -3
- package/scripts/review-pr-gate2.test.sh +15 -0
- package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
- package/scripts/review-pr-staging-scope.test.sh +65 -5
- package/scripts/review-prepare.sh +517 -0
- package/scripts/review-prepare.test.sh +270 -0
- package/scripts/review-roster.sh +172 -0
- package/scripts/review-roster.test.sh +163 -0
- package/scripts/run-shell-tests.sh +3 -0
- package/scripts/telemetry.test.sh +106 -0
- package/scripts/wait-ci-green.sh +8 -1
- package/templates/forge.yaml.minimal +9 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{"seq":1,"event":"RUN_START","issue":6,"run":"r_6_staging","lane":"staging","version":"2.0.0","repo":"o/a"}
|
|
2
|
+
{"seq":2,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":9999000}}
|
|
3
|
+
{"seq":3,"event":"RUN_TERMINAL","reason":"needs-human"}
|
|
4
|
+
{"seq":4,"event":"RUN_REOPEN","issue":6}
|
|
5
|
+
{"seq":5,"event":"PHASE_COMMIT","phase":"build","outputs":{},"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":1,"cache_read_input_tokens":9999000}}
|
|
@@ -279,15 +279,18 @@ HR_CLASSIFY="$ROOT/commands/review-pr.md"
|
|
|
279
279
|
HR_AMP="$ROOT/commands/orchestrate/phase-4-execution.md"
|
|
280
280
|
HR_EX3="$ROOT/commands/orchestrate/phase-3-dependency.md"
|
|
281
281
|
stub_block "$HR_CLASSIFY" CLASSIFY_SCRIPT > "$T/hr_classify.sh"
|
|
282
|
+
stub_block "$HR_CLASSIFY" CAP_SCRIPT > "$T/hr_cap.sh"
|
|
282
283
|
extract_helper_block "$HR_AMP" AMP_SCRIPT > "$T/hr_amp.sh"
|
|
283
284
|
extract_helper_fn "$HR_AMP" > "$T/hr_ex4.sh"
|
|
284
285
|
extract_helper_fn "$HR_EX3" > "$T/hr_ex3.sh"
|
|
285
286
|
expect "CLASSIFY_SCRIPT resolver stub found" 1 "$(grep -c '^CLASSIFY_SCRIPT=\$(forge_resolve classify-finding.sh)$' "$T/hr_classify.sh")"
|
|
287
|
+
expect "CAP_SCRIPT resolver stub found" 1 "$(grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$' "$T/hr_cap.sh")"
|
|
288
|
+
expect "CAP_SCRIPT resolver stub found in review-pr-staging.md" 1 "$(sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr-staging.md" | grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$')"
|
|
286
289
|
expect "AMP_SCRIPT resolver block found" 1 "$(grep -c '^AMP_SCRIPT=""' "$T/hr_amp.sh")"
|
|
287
290
|
expect "extract-affected-files resolver found in phase-4" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex4.sh")"
|
|
288
291
|
expect "extract-affected-files resolver found in phase-3" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex3.sh")"
|
|
289
292
|
cmp -s "$T/hr_ex3.sh" "$T/hr_ex4.sh" && ok || bad 'resolve_extract_affected_files copies differ between phase-3 and phase-4'
|
|
290
|
-
for hr in classify amp ex3 ex4; do
|
|
293
|
+
for hr in classify cap amp ex3 ex4; do
|
|
291
294
|
grep -qF 'plugins/cache/forgedock/forgedock' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks the plugin-cache scan"
|
|
292
295
|
grep -qF 'CLAUDE_CONFIG_DIR' "$T/hr_$hr.sh" && ok || bad "$hr helper resolver lacks CLAUDE_CONFIG_DIR"
|
|
293
296
|
grep -qE '\$PWD|\$REPO_PATH|\$\{PWD|\$\{REPO_PATH' "$T/hr_$hr.sh" && bad "$hr helper resolver lists the working directory or repo path" || ok
|
|
@@ -298,7 +301,7 @@ if grep -rnE 'PWD/scripts/|^\$PWD$|REPO_PATH/scripts/extract' "$ROOT/commands" >
|
|
|
298
301
|
# Behavior (#3483): a planted scripts/<helper>.sh in the cwd must never be selected; a cache-only install must resolve.
|
|
299
302
|
mkdir -p "$T/hrwork/scripts" "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/lib"
|
|
300
303
|
cp "$ROOT/scripts/lib/forge-resolve.sh" "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/lib/forge-resolve.sh"
|
|
301
|
-
for h in classify-finding amplification-breaker extract-affected-files; do
|
|
304
|
+
for h in classify-finding cap-findings amplification-breaker extract-affected-files; do
|
|
302
305
|
: > "$T/hrwork/scripts/$h.sh"; : > "$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts/$h.sh"
|
|
303
306
|
done
|
|
304
307
|
CACHE_SCRIPTS="$T/hrhome/.claude/plugins/cache/forgedock/forgedock/1.12.0/scripts"
|
|
@@ -307,6 +310,8 @@ hr_run() { # home blockfile expr
|
|
|
307
310
|
}
|
|
308
311
|
expect "CLASSIFY_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/classify-finding.sh" "$(hr_run "$T/hrhome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
|
|
309
312
|
expect "CLASSIFY_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_classify.sh" 'printf %s "$CLASSIFY_SCRIPT"')"
|
|
313
|
+
expect "CAP_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/cap-findings.sh" "$(hr_run "$T/hrhome" "$T/hr_cap.sh" 'printf %s "$CAP_SCRIPT"')"
|
|
314
|
+
expect "CAP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_cap.sh" 'printf %s "$CAP_SCRIPT"')"
|
|
310
315
|
expect "AMP_SCRIPT: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/amplification-breaker.sh" "$(hr_run "$T/hrhome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
|
|
311
316
|
expect "AMP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_amp.sh" 'printf %s "$AMP_SCRIPT"')"
|
|
312
317
|
expect "extract-affected-files: cache-only install resolves (cwd and REPO_PATH copies ignored)" "$CACHE_SCRIPTS/extract-affected-files.sh" "$(hr_run "$T/hrhome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
|
|
@@ -411,7 +416,7 @@ sed 's/^[[:space:]]*//' "$STG_SPEC" | grep '^type forge_resolve >/dev/null' | so
|
|
|
411
416
|
[ "$(wc -l < "$T/loc_uniq" | tr -d ' ')" = 1 ] && ok || bad "forge_resolve locator line must be byte-identical across every stub in review-pr-staging.md"
|
|
412
417
|
[ "$(awk '{print $1}' "$T/loc_uniq")" -ge 30 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (11) stub"
|
|
413
418
|
grep -qE 'PWD|\$\(pwd\)|REPO_PATH' "$T/locator.sh" && bad "stub locator must not consult the cwd or repo under review" || ok
|
|
414
|
-
[ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" =
|
|
419
|
+
[ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 22 ] && ok || bad "expected 22 fence bootstrap stubs"
|
|
415
420
|
[ "$(grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sort -u | wc -l | tr -d ' ')" = 1 ] && ok || bad "fence bootstrap stubs must be byte-identical"
|
|
416
421
|
grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sed 1q | grep -qF 'exit 1' && ok || bad "fence bootstrap stub must fail closed (exit 1) when the helper is unresolved"
|
|
417
422
|
# the spec carries none of the old inlined blocks
|
|
@@ -10,7 +10,8 @@ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
|
|
|
10
10
|
[ -n "${GH_REPO:-}" ] || GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE" 2>/dev/null)
|
|
11
11
|
[ -n "${DEFAULT_BRANCH:-}" ] || DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE" 2>/dev/null)
|
|
12
12
|
[ -n "${STAGING_BRANCH:-}" ] || STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE" 2>/dev/null)
|
|
13
|
-
# numeric-validated: a tampered file never reaches gh
|
|
14
|
-
|
|
13
|
+
# run-keyed pointer (concurrent runs share $_SD); numeric-validated: a tampered file never reaches gh
|
|
14
|
+
FORGE_RUN_KEY="${FORGE_RUN_KEY:-${FORGE_RUN_ID:-${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-${CODEX_THREAD_ID:-${OPENCODE_SESSION_ID:-$PPID}}}}}}"; FORGE_RUN_KEY=$(printf '%s' "$FORGE_RUN_KEY" | tr -c 'A-Za-z0-9_-' '_')
|
|
15
|
+
[ -n "${PR_NUMBER:-}" ] || { [ -r "$_SD/staging_pr_number.${FORGE_RUN_KEY}" ] && PR_NUMBER=$(sed -n '1{/^[0-9][0-9]*$/p;q}' "$_SD/staging_pr_number.${FORGE_RUN_KEY}"); }
|
|
15
16
|
[ -n "${ALL_PR_NUMBERS:-}" ] || { [ -n "${PR_NUMBER:-}" ] && [ -r "$_SD/${PR_NUMBER}_bundle-prs.txt" ] && ALL_PR_NUMBERS=$(grep -E '^[0-9]+$' "$_SD/${PR_NUMBER}_bundle-prs.txt"); }
|
|
16
|
-
[ -n "${PR_NUMBER:-}" ] || echo "WARNING: PR_NUMBER is empty after reload (no staging_pr_number in $_SD): this fence cannot key its scratch files or post to a PR."
|
|
17
|
+
[ -n "${PR_NUMBER:-}" ] || echo "WARNING: PR_NUMBER is empty after reload (no staging_pr_number.${FORGE_RUN_KEY} in $_SD): this fence cannot key its scratch files or post to a PR."
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pr-issue-number.sh: sourced helper — the ONE numeric-validated "Closes #N" extractor for a PR body.
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Source it, never exec it (resolve with `forge_resolve lib/pr-issue-number.sh`).
|
|
7
|
+
# pr_issue_number reads the PR body on stdin
|
|
8
|
+
# pr_issue_number_of <body> same, body as an argument
|
|
9
|
+
# Prints the number of the FIRST `Closes|Fixes|Resolves #N` reference (any keyword form, any case), or NOTHING when the body
|
|
10
|
+
# has none. The output is always ^[0-9]+$ or empty, so it is safe to place in an API path. A hand-rolled jq `gsub` returns the
|
|
11
|
+
# whole input when the pattern does not match, which is why every extraction goes through this helper instead.
|
|
12
|
+
pr_issue_number() {
|
|
13
|
+
local n
|
|
14
|
+
n=$(grep -oiE '(close[sd]?|fix(e[sd])?|resolve[sd]?):?[[:space:]]+#[0-9]+' 2>/dev/null | head -n 1 | grep -oE '[0-9]+$' | head -n 1)
|
|
15
|
+
case "$n" in ''|*[!0-9]*) return 0 ;; esac
|
|
16
|
+
printf '%s\n' "$n"
|
|
17
|
+
}
|
|
18
|
+
pr_issue_number_of() { printf '%s\n' "${1:-}" | pr_issue_number; }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-template-resolve.sh: sourced helper — the ONE resolution chain for the review-pr-agents persona templates.
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Source it, never exec it. `review_template_resolve [repo_path]` sets:
|
|
7
|
+
# TEMPLATE_SOURCE plugin_root | forge_home | repo_path | monolithic_catalog | none
|
|
8
|
+
# TEMPLATE_BASE directory holding protocols.md and the persona files (empty for monolithic_catalog / none)
|
|
9
|
+
# MONOLITHIC_CATALOG path of the last-resort catalog (monolithic_catalog only)
|
|
10
|
+
# Chain: the running plugin root (CLAUDE_PLUGIN_ROOT, absolute only), then FORGE_HOME, then the trusted install root from
|
|
11
|
+
# forge_root when the resolver is loaded, then the repo path argument, then the monolithic catalog (it must contain `### Agent:`
|
|
12
|
+
# headers: a post-split repo ships a router stub at that path that points back to the missing directory). `none` is a fatal setup
|
|
13
|
+
# error: the caller must stop and never review inline. The repo path is data, only ever read for template files.
|
|
14
|
+
review_template_resolve() {
|
|
15
|
+
local _plugin _root _repo="${1:-}"
|
|
16
|
+
TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""
|
|
17
|
+
_plugin="${CLAUDE_PLUGIN_ROOT:-}"; case "$_plugin" in /*) ;; *) _plugin="" ;; esac
|
|
18
|
+
if [ -n "$_plugin" ] && [ -f "$_plugin/commands/review-pr-agents/protocols.md" ]; then
|
|
19
|
+
TEMPLATE_BASE="$_plugin/commands/review-pr-agents"; TEMPLATE_SOURCE="plugin_root"; return 0
|
|
20
|
+
fi
|
|
21
|
+
case "${FORGE_HOME:-}" in
|
|
22
|
+
/*) if [ -f "$FORGE_HOME/commands/review-pr-agents/protocols.md" ]; then
|
|
23
|
+
TEMPLATE_BASE="$FORGE_HOME/commands/review-pr-agents"; TEMPLATE_SOURCE="forge_home"; return 0
|
|
24
|
+
fi ;;
|
|
25
|
+
esac
|
|
26
|
+
if type forge_root >/dev/null 2>&1; then
|
|
27
|
+
_root=$(forge_root)
|
|
28
|
+
if [ -n "$_root" ] && [ -f "$_root/commands/review-pr-agents/protocols.md" ]; then
|
|
29
|
+
TEMPLATE_BASE="$_root/commands/review-pr-agents"; TEMPLATE_SOURCE="forge_home"; return 0
|
|
30
|
+
fi
|
|
31
|
+
fi
|
|
32
|
+
if [ -n "$_repo" ]; then
|
|
33
|
+
if [ -f "$_repo/commands/review-pr-agents/protocols.md" ]; then
|
|
34
|
+
TEMPLATE_BASE="$_repo/commands/review-pr-agents"; TEMPLATE_SOURCE="repo_path"; return 0
|
|
35
|
+
fi
|
|
36
|
+
if [ -f "$_repo/commands/review-pr-agents.md" ] && grep -q "^### Agent:" "$_repo/commands/review-pr-agents.md" 2>/dev/null; then
|
|
37
|
+
MONOLITHIC_CATALOG="$_repo/commands/review-pr-agents.md"; TEMPLATE_SOURCE="monolithic_catalog"; return 0
|
|
38
|
+
fi
|
|
39
|
+
fi
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# telemetry.sh — fail-safe structured telemetry emitter (sourced helper, or exec'd as a CLI).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# . scripts/lib/telemetry.sh; forge_emit <event> key=value... # sourced
|
|
7
|
+
# bash scripts/lib/telemetry.sh <event> key=value... # exec'd (spec call sites)
|
|
8
|
+
#
|
|
9
|
+
# One compact JSON line per call: v, ts, forge_version, repo, issue, pr, run, phase, event + payload. GitHub is the
|
|
10
|
+
# canonical store: the line is appended to the trusted `<!-- FORGE:TELEMETRY v1 -->` comment on the issue (or PR for
|
|
11
|
+
# review-side events), edited in place (JSONL inside a fenced block), rolling over to `... v1 part=N` near 60000 chars.
|
|
12
|
+
# A failed write is appended to a local buffer and flushed by the next emit for the same target; the buffer is only
|
|
13
|
+
# a cache, everything flushed is recoverable from GitHub alone. The emitter NEVER fails or changes the caller:
|
|
14
|
+
# always returns 0, all output is discarded, `FORGE_TELEMETRY=off` disables it. It records ids, counts and codes only,
|
|
15
|
+
# never comment bodies, file contents or secrets (values are charset-restricted and truncated).
|
|
16
|
+
#
|
|
17
|
+
# Context env: FORGE_REPO (default: parsed from `git remote get-url origin`), FORGE_ISSUE, FORGE_PR, FORGE_RUN,
|
|
18
|
+
# FORGE_PHASE; null when unset. With no repo or no numeric target the record is dropped (nothing to write to).
|
|
19
|
+
# Test hooks: FORGE_TELEMETRY_GH (gh replacement), FORGE_TELEMETRY_BUFFER_DIR, FORGE_TELEMETRY_MAX_CHARS.
|
|
20
|
+
# Comment trust is decided by scripts/trusted-comments.sh (the one trust predicate), resolved next to this file.
|
|
21
|
+
# bash 3.2 portable.
|
|
22
|
+
|
|
23
|
+
_FORGE_TEL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
|
|
24
|
+
|
|
25
|
+
_forge_tel_clean() { # sanitize a payload value: restricted charset, max 200 chars
|
|
26
|
+
printf '%s' "$1" | tr '\n\r\t' ' ' | tr -c 'A-Za-z0-9 _.:/#@+=,-' '_' | cut -c1-200
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_forge_tel_record() { # event k=v... -> one JSON line on stdout
|
|
30
|
+
local ev="$1"; shift
|
|
31
|
+
local ver="" repo="${FORGE_REPO:-}" args=() kv k v
|
|
32
|
+
if [ -z "$repo" ]; then
|
|
33
|
+
repo=$(git remote get-url origin 2>/dev/null | sed -E 's#^.*[:/]([^/:]+/[^/]+)$#\1#; s#\.git$##')
|
|
34
|
+
fi
|
|
35
|
+
ver=$(jq -r '.version // empty' "$_FORGE_TEL_DIR/../../package.json" 2>/dev/null)
|
|
36
|
+
for kv in "$@"; do
|
|
37
|
+
k="${kv%%=*}"; v="${kv#*=}"
|
|
38
|
+
case "$kv" in *=*) ;; *) continue ;; esac
|
|
39
|
+
case "$k" in ''|*[!A-Za-z0-9_]*) continue ;; esac
|
|
40
|
+
case "$k" in v|ts|forge_version|repo|issue|pr|run|phase|event|eid) continue ;; esac
|
|
41
|
+
args+=("$k" "$(_forge_tel_clean "$v")")
|
|
42
|
+
done
|
|
43
|
+
jq -nc --arg ev "$ev" --arg ver "$ver" --arg repo "$repo" --arg issue "${FORGE_ISSUE:-}" --arg pr "${FORGE_PR:-}" \
|
|
44
|
+
--arg run "${FORGE_RUN:-}" --arg phase "${FORGE_PHASE:-}" --arg eid "$(date +%s)-$$-${RANDOM}${RANDOM}${RANDOM}" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '
|
|
45
|
+
def num: if test("^[0-9]+$") then tonumber else null end;
|
|
46
|
+
def str: if . == "" then null else . end;
|
|
47
|
+
($ARGS.positional | [range(0; length; 2) as $i | {key: .[$i], value: .[$i+1]}]
|
|
48
|
+
| map(.value |= (if test("^-?[0-9]+$") then tonumber elif . == "true" then true elif . == "false" then false else . end))
|
|
49
|
+
| from_entries) as $p
|
|
50
|
+
| {v: 1, ts: $ts, forge_version: ($ver | str), repo: ($repo | str), issue: ($issue | num), pr: ($pr | num),
|
|
51
|
+
run: ($run | str), phase: ($phase | str), event: $ev, eid: $eid} + $p' --args "${args[@]}"
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Prints "<number> <repo>" of the comment target (issue or PR) for this event, or nothing when none is usable.
|
|
55
|
+
_forge_tel_target() {
|
|
56
|
+
local ev="$1" repo="$2" n
|
|
57
|
+
case "$ev" in
|
|
58
|
+
review_panel|finding_disposition|ci_gate) n="${FORGE_PR:-}"; case "$n" in ''|*[!0-9]*) n="${FORGE_ISSUE:-}" ;; esac ;;
|
|
59
|
+
*) n="${FORGE_ISSUE:-}"; case "$n" in ''|*[!0-9]*) n="${FORGE_PR:-}" ;; esac ;;
|
|
60
|
+
esac
|
|
61
|
+
case "$n" in ''|*[!0-9]*) return 0 ;; esac
|
|
62
|
+
case "$repo" in */*) ;; *) return 0 ;; esac
|
|
63
|
+
printf '%s %s\n' "$n" "$repo"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Appends the JSONL lines on stdin to the trusted telemetry comment of <repo>#<n>; returns non-zero on any failure.
|
|
67
|
+
_forge_tel_upsert() {
|
|
68
|
+
local repo="$1" n="$2" gh="${FORGE_TELEMETRY_GH:-gh}" max="${FORGE_TELEMETRY_MAX_CHARS:-60000}"
|
|
69
|
+
local nl fence new comments cand id body part best_id="" best_part=0 best_body="" all_bodies="" lines hdr out tc
|
|
70
|
+
new=$(cat); [ -n "$new" ] || return 0
|
|
71
|
+
tc="$_FORGE_TEL_DIR/../trusted-comments.sh"; [ -f "$tc" ] || return 1
|
|
72
|
+
command -v jq >/dev/null 2>&1 || return 1
|
|
73
|
+
local to=""; command -v timeout >/dev/null 2>&1 && to="timeout 30"
|
|
74
|
+
comments=$($to "$gh" api --paginate "repos/$repo/issues/$n/comments" 2>/dev/null) || return 1
|
|
75
|
+
[ -n "$comments" ] || comments='[]'
|
|
76
|
+
# Candidates carry the marker; each is then checked individually against the trust predicate.
|
|
77
|
+
while IFS= read -r cand; do
|
|
78
|
+
[ -n "$cand" ] || continue
|
|
79
|
+
id=$(printf '%s' "$cand" | jq -r '.id')
|
|
80
|
+
[ "$(printf '[%s]' "$cand" | bash "$tc" count '^<!-- FORGE:TELEMETRY v1( part=[0-9]+)? -->' 2>/dev/null)" = "1" ] || continue
|
|
81
|
+
body=$(printf '%s' "$cand" | jq -r '.body')
|
|
82
|
+
all_bodies="${all_bodies}${body}"$'\n'
|
|
83
|
+
part=$(printf '%s' "$body" | sed -n '1s/^<!-- FORGE:TELEMETRY v1 part=\([0-9]*\) -->.*/\1/p'); part="${part:-1}"
|
|
84
|
+
if [ "$part" -ge "$best_part" ]; then best_part="$part"; best_id="$id"; best_body="$body"; fi
|
|
85
|
+
done < <(printf '%s' "$comments" | jq -c -s '.[][] | select((.body // "") | test("^<!-- FORGE:TELEMETRY v1( part=[0-9]+)? -->")) | {id, body, user, author_association}' 2>/dev/null)
|
|
86
|
+
# Drop lines any part already holds (a PATCH whose reply was lost, or a re-flushed buffer). Every record carries a
|
|
87
|
+
# unique eid, so two distinct same-second events are never byte-identical and are never dropped by this check.
|
|
88
|
+
lines=""
|
|
89
|
+
while IFS= read -r l; do
|
|
90
|
+
[ -n "$l" ] || continue
|
|
91
|
+
case "$all_bodies" in *"$l"*) continue ;; esac
|
|
92
|
+
lines="${lines}${l}"$'\n'
|
|
93
|
+
done <<< "$new"
|
|
94
|
+
[ -n "$lines" ] || return 0
|
|
95
|
+
# bash 3.2: $'..' inside a double-quoted ${..%..} is NOT expanded (and backticks there start a command
|
|
96
|
+
# substitution), so the newline and fence live in variables and the pattern quotes them.
|
|
97
|
+
nl=$'\n'; fence='```'
|
|
98
|
+
lines="${lines%"$nl"}"
|
|
99
|
+
if [ -n "$best_id" ]; then
|
|
100
|
+
local cur="${best_body%"$nl$fence"*}" # body up to the closing fence
|
|
101
|
+
out="${cur}"$'\n'"${lines}"$'\n```'
|
|
102
|
+
if [ "${#out}" -le "$max" ]; then
|
|
103
|
+
$to "$gh" api -X PATCH "repos/$repo/issues/comments/$best_id" -f body="$out" >/dev/null 2>&1; return $?
|
|
104
|
+
fi
|
|
105
|
+
hdr="<!-- FORGE:TELEMETRY v1 part=$((best_part + 1)) -->"
|
|
106
|
+
else
|
|
107
|
+
hdr="<!-- FORGE:TELEMETRY v1 -->"
|
|
108
|
+
fi
|
|
109
|
+
out="${hdr}"$'\n```jsonl\n'"${lines}"$'\n```'
|
|
110
|
+
$to "$gh" api -X POST "repos/$repo/issues/$n/comments" -f body="$out" >/dev/null 2>&1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
_forge_tel_impl() {
|
|
114
|
+
local ev="${1:-}"; shift
|
|
115
|
+
[ "${FORGE_TELEMETRY:-on}" = "off" ] && return 0
|
|
116
|
+
case "$ev" in ''|*[!A-Za-z0-9_.-]*) return 0 ;; esac
|
|
117
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
118
|
+
local rec repo tgt n buf bdir pending
|
|
119
|
+
rec=$(_forge_tel_record "$ev" "$@") && [ -n "$rec" ] || return 0
|
|
120
|
+
repo=$(printf '%s' "$rec" | jq -r '.repo // ""')
|
|
121
|
+
tgt=$(_forge_tel_target "$ev" "$repo"); [ -n "$tgt" ] || return 0
|
|
122
|
+
n="${tgt%% *}"
|
|
123
|
+
bdir="${FORGE_TELEMETRY_BUFFER_DIR:-$HOME/.forge/telemetry-buffer}"
|
|
124
|
+
buf="$bdir/$(printf '%s' "$repo" | tr '/' '_')__${n}.jsonl"
|
|
125
|
+
# Serialize emitters on this host per target: the GitHub read-modify-write has no version check, so two overlapping
|
|
126
|
+
# emitters would each PATCH a stale snapshot (lost record) or both POST a first/rollover part (duplicate comments).
|
|
127
|
+
# Bounded wait (every loop pass counts, 50 x 0.1s); a lock over 5 min old (longer than any bounded hold: two 30s gh
|
|
128
|
+
# calls plus trust checks) is stale and reclaimed by an atomic rename so only one waiter wins; the holder writes an
|
|
129
|
+
# owner token and releases only its own lock. A non-writable buffer dir skips locking. On timeout the record is
|
|
130
|
+
# buffered, never dropped.
|
|
131
|
+
local lock="$bdir/.lock-$(printf '%s' "$repo" | tr '/' '_')__${n}" tries=0 got="" token="$$.${RANDOM}${RANDOM}"
|
|
132
|
+
if ! { mkdir -p "$bdir" 2>/dev/null && [ -w "$bdir" ]; }; then tries=50; got=nolock; fi
|
|
133
|
+
while [ "$tries" -lt 50 ]; do
|
|
134
|
+
if mkdir "$lock" 2>/dev/null; then printf '%s' "$token" > "$lock/owner" 2>/dev/null; got=1; break; fi
|
|
135
|
+
if [ -n "$(find "$lock" -maxdepth 0 -mmin +5 2>/dev/null)" ]; then
|
|
136
|
+
mv "$lock" "$lock.stale.$token" 2>/dev/null && rm -rf "$lock.stale.$token" 2>/dev/null
|
|
137
|
+
else
|
|
138
|
+
sleep 0.1 2>/dev/null || sleep 1
|
|
139
|
+
fi
|
|
140
|
+
tries=$((tries + 1))
|
|
141
|
+
done
|
|
142
|
+
if [ -z "$got" ]; then printf '%s\n' "$rec" >> "$buf" 2>/dev/null; return 0; fi
|
|
143
|
+
_forge_tel_unlock() { [ "$got" = 1 ] && [ "$(cat "$lock/owner" 2>/dev/null)" = "$token" ] && rm -rf "$lock" 2>/dev/null; got=""; }
|
|
144
|
+
trap '_forge_tel_unlock' EXIT; trap '_forge_tel_unlock; exit 1' HUP INT TERM
|
|
145
|
+
# Claim the buffer by renaming it first: a record appended by another emitter after this point lands in a fresh
|
|
146
|
+
# buffer file and survives. Holding the lock, any other "$buf.flush.*" is an orphan of a killed emitter: fold it in.
|
|
147
|
+
local claimed="$buf.flush.$token" f
|
|
148
|
+
pending="$rec"
|
|
149
|
+
if [ -s "$buf" ]; then mv "$buf" "$claimed" 2>/dev/null; fi
|
|
150
|
+
for f in "$buf".flush.*; do
|
|
151
|
+
[ -f "$f" ] && [ "$f" != "$claimed" ] && cat "$f" >> "$claimed" 2>/dev/null && rm -f "$f" 2>/dev/null
|
|
152
|
+
done
|
|
153
|
+
if [ -s "$claimed" ]; then pending="$(cat "$claimed")"$'\n'"$rec"; fi
|
|
154
|
+
if printf '%s\n' "$pending" | _forge_tel_upsert "$repo" "$n"; then
|
|
155
|
+
rm -f "$claimed" 2>/dev/null
|
|
156
|
+
else
|
|
157
|
+
if [ -f "$claimed" ]; then cat "$claimed" >> "$buf" 2>/dev/null; rm -f "$claimed" 2>/dev/null; fi
|
|
158
|
+
printf '%s\n' "$rec" >> "$buf" 2>/dev/null
|
|
159
|
+
fi
|
|
160
|
+
_forge_tel_unlock
|
|
161
|
+
return 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Subshell + discarded output: no option, variable, trap or stream of the caller can be changed or polluted.
|
|
165
|
+
forge_emit() { ( set +eu +o pipefail 2>/dev/null; _forge_tel_impl "$@" ) >/dev/null 2>&1 </dev/null || true; return 0; }
|
|
166
|
+
|
|
167
|
+
# Exec'd as a CLI (spec call sites); sourcing only defines functions.
|
|
168
|
+
if [ -n "${BASH_SOURCE[0]:-}" ] && [ "${BASH_SOURCE[0]}" = "$0" ]; then forge_emit "$@"; exit 0; fi
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
# lease.by that is not the dispatching holder and not a provably dead same-host engine pid. An until past
|
|
13
13
|
# the TTL cap is not a lease an engine could have written (the issue body is author-editable), so it is dead.
|
|
14
14
|
# (b) review: the newest trusted FORGE:REVIEW_ROUTE comment is younger than REVIEW_PENDING_MAX_S and no trusted
|
|
15
|
-
# FORGE:REVIEW-AGENT:* comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction)
|
|
15
|
+
# FORGE:REVIEW-AGENT:* or FORGE:REVIEW-CARRIED comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction),
|
|
16
|
+
# and no trusted FORGE:{CI|INPR|BASESYNC}_REMEDIATION: pr=<pr> bound marker on the issue is at or after that route
|
|
17
|
+
# (a review that handed off to remediation, even before dispatching a panel, is never in flight).
|
|
16
18
|
# A deferral is bounded: after MAX_DEFERRALS trusted FORGE:REMEDIATION_DEFERRED records for this PR the answer is
|
|
17
19
|
# EXHAUSTED (a human is needed), so a flapping lease or a dead review can never park a PR forever.
|
|
18
20
|
#
|
|
@@ -23,6 +25,10 @@
|
|
|
23
25
|
# LIVENESS: ERROR <why> exit 2 unreadable state; fail closed (never "no lease", never delete)
|
|
24
26
|
# Trust predicate for comments: Bot, or OWNER/MEMBER/COLLABORATOR (same default as scripts/trusted-comments.sh).
|
|
25
27
|
set -uo pipefail
|
|
28
|
+
# Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
|
|
29
|
+
_TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
|
|
30
|
+
type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
|
|
31
|
+
[ -z "${DRY_RUN:-}" ] || FORGE_TELEMETRY=off
|
|
26
32
|
|
|
27
33
|
PR="${1:-}"; ISSUE="${2:-}"; REPO="${3:-}"; HOLDER="${4:-}"
|
|
28
34
|
MAX_LEASE_TTL_MS=1800000 # 3x the engine's 10 min lease TTL
|
|
@@ -39,7 +45,28 @@ NOW_S=$(( NOW_MS / 1000 ))
|
|
|
39
45
|
|
|
40
46
|
TRUST='((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))'
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
# Body + provenance (issue-body FORGE:STATE trust gate): the LAST EDITOR (editor // author) must be a Bot,
|
|
49
|
+
# or the creator with OWNER/MEMBER/COLLABORATOR, or a distinct editor with write/maintain/admin permission.
|
|
50
|
+
# Unreadable provenance fails closed. An untrusted body is treated as having no FORGE:STATE (no lease).
|
|
51
|
+
OWNER_PART="${REPO%%/*}"; NAME_PART="${REPO#*/}"
|
|
52
|
+
PROV_Q='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){issue(number:$number){body authorAssociation author{login __typename} editor{login __typename}}}}'
|
|
53
|
+
PROV=$(gh api graphql -f query="$PROV_Q" -F owner="$OWNER_PART" -F name="$NAME_PART" -F number="$ISSUE" 2>/dev/null) || err "issue unreadable"
|
|
54
|
+
ISSUE_JSON=$(printf '%s' "$PROV" | jq -c '.data.repository.issue | {body: (.body // "")}' 2>/dev/null) || err "issue unreadable"
|
|
55
|
+
ACTOR_ROW=$(printf '%s' "$PROV" | jq -r '.data.repository.issue | (.editor // .author) as $a | [($a.__typename // ""), ($a.login // ""), (if .editor != null and .editor.login != .author.login then "EDITOR" else (.authorAssociation // "NONE") end)] | @tsv' 2>/dev/null) || err "issue unreadable"
|
|
56
|
+
A_TYPE=$(printf '%s' "$ACTOR_ROW" | cut -f1); A_LOGIN=$(printf '%s' "$ACTOR_ROW" | cut -f2); A_ASSOC=$(printf '%s' "$ACTOR_ROW" | cut -f3)
|
|
57
|
+
BODY_TRUSTED=false
|
|
58
|
+
if [ "$A_TYPE" = "Bot" ]; then BODY_TRUSTED=true
|
|
59
|
+
else
|
|
60
|
+
case "$A_ASSOC" in
|
|
61
|
+
OWNER|MEMBER|COLLABORATOR) BODY_TRUSTED=true ;;
|
|
62
|
+
EDITOR)
|
|
63
|
+
if printf '%s' "$A_LOGIN" | grep -qE '^[A-Za-z0-9-]{1,39}$'; then
|
|
64
|
+
PERM=$(gh api "repos/$REPO/collaborators/$A_LOGIN/permission" --jq .permission 2>/dev/null || true)
|
|
65
|
+
case "$PERM" in admin|maintain|write) BODY_TRUSTED=true ;; esac
|
|
66
|
+
fi ;;
|
|
67
|
+
esac
|
|
68
|
+
fi
|
|
69
|
+
[ "$BODY_TRUSTED" = "true" ] || ISSUE_JSON='{"body":""}'
|
|
43
70
|
ISSUE_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments" 2>/dev/null) || err "issue comments unreadable"
|
|
44
71
|
PR_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null) || err "PR comments unreadable"
|
|
45
72
|
[ -n "$PR_COMMENTS" ] || err "PR comments unreadable"
|
|
@@ -63,22 +90,30 @@ if [ -n "$STATE_JSON" ]; then
|
|
|
63
90
|
fi
|
|
64
91
|
fi
|
|
65
92
|
|
|
66
|
-
# (b) a routed review that is still inside its window and has produced no verdict for the routed sha.
|
|
93
|
+
# (b) a routed review that is still inside its window and has produced no verdict (FORGE:REVIEW-AGENT or, for BASE_SYNC_ONLY, FORGE:REVIEW-CARRIED) for the routed sha.
|
|
67
94
|
ROUTE=$(printf '%s' "$PR_COMMENTS" | jq -rs "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW_ROUTE mode=[^ ]+ spec=review-pr.md sha=[0-9a-f]{7,40} -->\")) | select((.body | test(\"mode=spec-evolution-blocked\")) | not)] | last | if . == null then \"\" else ((.body | capture(\"sha=(?<s>[0-9a-f]{7,40})\").s) + \" \" + ((.created_at // \"\") | fromdateiso8601? // 0 | tostring)) end" 2>/dev/null) \
|
|
68
95
|
|| err "review comments unparseable"
|
|
69
96
|
ROUTE_SHA="${ROUTE%% *}"; ROUTE_AT="${ROUTE##* }"; REVIEW_PENDING=false
|
|
70
97
|
if [ -n "$ROUTE_SHA" ]; then
|
|
71
98
|
case "$ROUTE_AT" in ''|*[!0-9]*) ROUTE_AT=0 ;; esac
|
|
72
|
-
VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-AGENT:[a-z-]
|
|
73
|
-
|
|
99
|
+
VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-(AGENT:[a-z-]+|CARRIED) -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
|
|
100
|
+
# Handoff: the dispatcher posts FORGE:CI_REMEDIATION / FORGE:INPR_REMEDIATION / FORGE:BASESYNC_REMEDIATION: pr=N on the issue right before dispatching remediation, so a
|
|
101
|
+
# trusted marker at or after the route means the review handed off (possibly before any panel) and nothing is in flight. A route newer than the marker is a re-review.
|
|
102
|
+
HANDOFF_Q='[.[][] | select('"$TRUST"') | select((.body // "") | test("(^|\\n)[ \\t]*<!-- FORGE:(CI|INPR|BASESYNC)_REMEDIATION: " + $m + " -->[ \\t\\r]*(\\n|$)")) | ((.created_at // "") | fromdateiso8601? // 0)] | max // 0'
|
|
103
|
+
HANDOFF_AT=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg m "pr=$PR" "$HANDOFF_Q" 2>/dev/null) || err "handoff markers unreadable"
|
|
104
|
+
case "$HANDOFF_AT" in ''|*[!0-9]*) HANDOFF_AT=0 ;; esac
|
|
105
|
+
[ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] \
|
|
106
|
+
&& { [ "$HANDOFF_AT" -eq 0 ] || [ "$HANDOFF_AT" -lt "$ROUTE_AT" ]; } && REVIEW_PENDING=true
|
|
74
107
|
fi
|
|
75
108
|
|
|
76
109
|
if [ "$LEASE_LIVE" != "true" ] && [ "$REVIEW_PENDING" != "true" ]; then
|
|
110
|
+
FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=PROCEED why=dead
|
|
77
111
|
echo "LIVENESS: DEAD"; exit 0
|
|
78
112
|
fi
|
|
79
113
|
|
|
80
114
|
PRIOR=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg pr "pr=$PR" "[.[][] | select($TRUST) | select((.body // \"\") | startswith(\"<!-- FORGE:REMEDIATION_DEFERRED -->\")) | select((.body | split(\"\\n\") | index(\$pr)) != null)] | length" 2>/dev/null) || err "deferral records unreadable"
|
|
81
|
-
if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then
|
|
115
|
+
if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=EXHAUSTED why=deferrals-${PRIOR}
|
|
116
|
+
echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
|
|
82
117
|
|
|
83
118
|
if [ "$LEASE_LIVE" = "true" ]; then KIND=lease; DETAIL="lease held by ${LEASE_BY} until ${LEASE_UNTIL}"
|
|
84
119
|
else KIND=review; DETAIL="re-review of ${ROUTE_SHA} has no reviewer verdicts yet"; fi
|
|
@@ -93,4 +128,5 @@ lease_by=${LEASE_BY:-none}
|
|
|
93
128
|
lease_until=${LEASE_UNTIL}"
|
|
94
129
|
if [ -n "${DRY_RUN:-}" ]; then printf 'DRY_RUN: would post:\n%s\n' "$BODY"
|
|
95
130
|
else gh issue comment "$ISSUE" -R "$REPO" --body "$BODY" >/dev/null 2>&1 || err "could not post deferral record"; fi
|
|
131
|
+
FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$ISSUE}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit liveness decision=DEFER why="$KIND"
|
|
96
132
|
echo "LIVENESS: DEFER ${KIND} ${DETAIL}"; exit 10
|
|
@@ -4,13 +4,17 @@
|
|
|
4
4
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
5
|
#
|
|
6
6
|
# Usage: review-acceptance-gate.sh <issue> <gh_repo> "<gh_flag>" <worktree_path> (DRY_RUN=1 skips gh writes)
|
|
7
|
-
# Evaluates the ACCEPTANCE_CHECK lines the build gate (B6.5) deferred: `phase=review` checks, plus
|
|
8
|
-
#
|
|
7
|
+
# Evaluates the ACCEPTANCE_CHECK lines the build gate (B6.5) deferred: `phase=review` checks, plus command/behavior
|
|
8
|
+
# checks on a `gh pr` target (B6.5's legacy deferral ignores any phase= tag, so a tagged phase=build one is selected too). Uses its own FORGE:ACCEPTANCE_GATE_REVIEW marker, never the build
|
|
9
9
|
# gate's FORGE:ACCEPTANCE_GATE header or :PASSED/:FAILED/:BLOCKED sentinels (the engine reads those).
|
|
10
10
|
# Malformed checks (forge#3694 parity with B6.5: 4+ backslash run, `bash -n` parse failure, grep usage error) are skipped and listed,
|
|
11
11
|
# never a FAIL and never needs-human. With zero verified checks and some malformed, a non-blocking notice is posted (no PASSED marker).
|
|
12
12
|
# Exit: 0 passed / no-op / notice / already passed for this head, 1 a deferred check failed (issue gets needs-human).
|
|
13
13
|
set -uo pipefail
|
|
14
|
+
# Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
|
|
15
|
+
_TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
|
|
16
|
+
type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
|
|
17
|
+
[ -z "${DRY_RUN:-}" ] || FORGE_TELEMETRY=off
|
|
14
18
|
|
|
15
19
|
NUMBER="${1:?issue number}"; GH_REPO="${2:?repo}"; GH_FLAG="${3:-}"; WT="${4:-.}"
|
|
16
20
|
cd "$WT" || exit 2
|
|
@@ -42,6 +46,7 @@ if printf '%s' "$LAST" | grep -qF "<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HE
|
|
|
42
46
|
fi
|
|
43
47
|
|
|
44
48
|
CHECKS=$(printf '%s\n' "$TRUSTED_INV" | jq -r '.' 2>/dev/null | grep "^ACCEPTANCE_CHECK:")
|
|
49
|
+
GREP_CLS=$(forge_resolve classify-grep-exit.sh) # unresolved = grep exit>=2 stays a real FAIL (fail closed, forge#3802)
|
|
45
50
|
GATE_PASS=true; FAILED=""; VERIFIED_COUNT=0; MALFORMED_CHECKS=""; RESULTS=""
|
|
46
51
|
while IFS= read -r check_line; do
|
|
47
52
|
[ -z "$check_line" ] && continue
|
|
@@ -57,12 +62,12 @@ while IFS= read -r check_line; do
|
|
|
57
62
|
[ "$PHASE" = "review" ] || PHASE=build
|
|
58
63
|
case "$TYPE" in command|behavior) ;; *) [ "$PHASE" = "review" ] || continue ;; esac
|
|
59
64
|
if [ "$PHASE" != "review" ]; then
|
|
60
|
-
printf '%s\n' "$check_line" | grep -qE ' phase=(build|review)( |$)' && continue
|
|
61
65
|
case "$TARGET" in *'gh pr'*) ;; *) continue ;; esac
|
|
62
66
|
fi
|
|
63
67
|
# malformed-check (forge#3694): static screen only parses (bash -n), never executes.
|
|
64
68
|
MALFORMED=""
|
|
65
69
|
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
|
|
70
|
+
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && [ -z "$MALFORMED" ] && case "$(printf '%s' "$TARGET" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in none) MALFORMED='target "none" is not a command (a manual criterion must be type=skipped)';; esac
|
|
66
71
|
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
67
72
|
RESULT="PASS"; DETAIL=""
|
|
68
73
|
case "$TYPE" in
|
|
@@ -72,12 +77,14 @@ while IFS= read -r check_line; do
|
|
|
72
77
|
command|behavior)
|
|
73
78
|
if [ "$MATCHER" = "exit_0" ]; then
|
|
74
79
|
ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
|
|
75
|
-
# grep exit >= 2 is a usage error, not a verdict; a
|
|
76
|
-
[ "$RC" -ge 2 ] &&
|
|
80
|
+
# grep exit >= 2 is a usage error, not a verdict; only a positively identified usage error (classify-grep-exit.sh) is malformed; a missing operand stays a real FAIL even with stderr suppressed.
|
|
81
|
+
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
82
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
77
83
|
[ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
|
|
78
84
|
else
|
|
79
|
-
OUTPUT=$(eval "$TARGET" 2
|
|
80
|
-
[ "$RC" -ge 2 ] &&
|
|
85
|
+
ERRF=$(mktemp); OUTPUT=$(eval "$TARGET" 2>"$ERRF"); RC=$?; ERR=$(cat "$ERRF"); rm -f "$ERRF"; [ -z "$ERR" ] || OUTPUT="${OUTPUT}${OUTPUT:+$'\n'}${ERR}" # classify on stderr only, match on both
|
|
86
|
+
[ "$RC" -ge 2 ] && MALFORMED=$(bash "$GREP_CLS" "$RC" "$ERR" "$TARGET" 2>/dev/null)
|
|
87
|
+
[ "$RC" -eq 127 ] && [ -z "$MALFORMED" ] && printf '%s' "$ERR" | grep -qF 'command not found' && MALFORMED="command not found (rc 127): the target is not an executable command"
|
|
81
88
|
if [ -n "$MALFORMED" ]; then :
|
|
82
89
|
else
|
|
83
90
|
printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER" || printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER" \
|
|
@@ -95,6 +102,8 @@ done <<< "$CHECKS"
|
|
|
95
102
|
MALFORMED_SECTION=""
|
|
96
103
|
[ -n "$MALFORMED_CHECKS" ] && MALFORMED_SECTION=$(printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS")
|
|
97
104
|
|
|
105
|
+
_nf=$(printf '%b' "$FAILED" | grep -c '^- ' || true); _nm=$(printf '%b' "$MALFORMED_CHECKS" | grep -c '^- ' || true)
|
|
106
|
+
FORGE_REPO="${FORGE_REPO:-$GH_REPO}" FORGE_ISSUE="${FORGE_ISSUE:-$NUMBER}" forge_emit acceptance_gate phase=review verified="$VERIFIED_COUNT" failed="${_nf:-0}" malformed="${_nm:-0}" deferred=$((VERIFIED_COUNT + ${_nm:-0}))
|
|
98
107
|
if [ "$VERIFIED_COUNT" -eq 0 ] && [ -z "$MALFORMED_CHECKS" ]; then
|
|
99
108
|
echo "No deferred acceptance checks; R2.5 is a no-op."
|
|
100
109
|
elif [ "$VERIFIED_COUNT" -eq 0 ]; then
|
|
@@ -75,6 +75,9 @@ inv "$(chk ac-1 false)"; rc=$(run); eq "real fail: rc 1" "$rc" 1
|
|
|
75
75
|
grep -q 'needs-human' "$TMP/out" && ok "real fail: needs-human added" || bad "real fail: no needs-human"
|
|
76
76
|
inv "$(chk ac-1 'grep -q nomatch f.txt')"; rc=$(run); eq "grep exit 1 is a real fail" "$rc" 1
|
|
77
77
|
inv "$(chk ac-1 'grep -q x nofile.txt')"; rc=$(run); eq "grep missing operand file stays a real fail" "$rc" 1
|
|
78
|
+
inv "$(chk ac-1 'grep -q x nofile.txt 2>/dev/null')"; rc=$(run); eq "stderr-suppressed missing operand is a real fail (forge#3802)" "$rc" 1
|
|
79
|
+
grep -q ':PASSED:' "$TMP/out" && bad "stderr-suppressed missing operand wrote PASSED" || ok "stderr-suppressed missing operand: no PASSED marker"
|
|
80
|
+
inv "$(chk ac-1 'grep -sq x nofile.txt')"; rc=$(run); eq "grep -s missing operand is a real fail (forge#3802)" "$rc" 1
|
|
78
81
|
|
|
79
82
|
for t in "echo 'unbalanced" 'echo a\\\\\\\\b' "grep -E '[' f.txt"; do
|
|
80
83
|
inv "$(chk ac-7 "$t")"; rc=$(run); eq "malformed skipped, rc 0: $t" "$rc" 0
|
|
@@ -87,4 +90,14 @@ inv "$(chk ac-1 true)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed pa
|
|
|
87
90
|
grep -q ':PASSED:' "$TMP/out" && grep -qi 'malformed' "$TMP/out" && ok "mixed: PASSED with malformed listed" || bad "mixed: wrong output"
|
|
88
91
|
inv "$(chk ac-1 false)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed fail+malformed: fails closed" "$rc" 1
|
|
89
92
|
|
|
93
|
+
# Selection parity with B6.5 (forge#3809): non-review checks are selected by the `gh pr` target only, never by a phase= text match.
|
|
94
|
+
bt() { printf 'ACCEPTANCE_CHECK: id=%s type=command target="%s" matcher="exit_0" phase=%s description=%s' "$1" "$2" "$3" "$4"; }
|
|
95
|
+
inv "$(bt ac-1 'gh pr view 1 && false' build d)"; rc=$(run); eq "phase=build gh pr check is evaluated (fails)" "$rc" 1
|
|
96
|
+
inv "$(bt ac-1 'gh pr view 1 && true' build d)"; rc=$(run)
|
|
97
|
+
grep -q ':PASSED:' "$TMP/out" && ok "phase=build gh pr check counted and passes" || bad "phase=build gh pr check not counted"
|
|
98
|
+
inv "$(bt ac-1 'false' build d)"; rc=$(run); eq "phase=build non-gh-pr check is not selected" "$rc" 0
|
|
99
|
+
inv "$(bt ac-1 'gh pr view 1 && false' build 'x phase=review y')"; rc=$(run); eq "phase=review in description does not change selection" "$rc" 1
|
|
100
|
+
inv "$(bt ac-1 'false' build 'x phase=review y')"; rc=$(run); eq "phase=review in description does not select a non-gh-pr check" "$rc" 0
|
|
101
|
+
inv 'ACCEPTANCE_CHECK: id=ac-1 type=command target="gh pr view 1 && false" matcher="exit_0" description=untagged'; rc=$(run); eq "untagged gh pr check is evaluated" "$rc" 1
|
|
102
|
+
|
|
90
103
|
echo "$PASS passed, $FAILN failed"; [ "$FAILN" -eq 0 ]
|
|
@@ -27,7 +27,12 @@ awk -v dir="$WORK" '
|
|
|
27
27
|
/^# END review-pr-agent-domain/ {on=0; close(out)}
|
|
28
28
|
' "$SPEC"
|
|
29
29
|
copies=$(ls "$WORK"/helper.* 2>/dev/null | wc -l | tr -d ' ')
|
|
30
|
-
if [ "$copies" -ge
|
|
30
|
+
if [ "$copies" -ge 3 ]; then ok; else bad "expected >=3 inline agent_domain copies (canonicalize, dispatch, phase 4), found $copies"; fi
|
|
31
|
+
# The roster script carries its own copy of the helper; it must match the inline copies (forge#3599).
|
|
32
|
+
ROSTER="$ROOT/scripts/review-roster.sh"
|
|
33
|
+
want_fn=$(grep '^agent_domain()' "$WORK/helper.1")
|
|
34
|
+
got_fn=$(grep '^agent_domain()' "$ROSTER")
|
|
35
|
+
if [ -n "$want_fn" ] && [ "$want_fn" = "$got_fn" ]; then ok; else bad "scripts/review-roster.sh agent_domain differs from the inline copies"; fi
|
|
31
36
|
for f in "$WORK"/helper.*; do
|
|
32
37
|
[ -f "$f" ] || continue
|
|
33
38
|
if cmp -s "$f" "$WORK/helper.1"; then ok; else bad "$(basename "$f") differs from helper.1"; fi
|
|
@@ -54,8 +59,8 @@ for l in API Api; do check "$l" api; done
|
|
|
54
59
|
check "CustomDomain" customdomain
|
|
55
60
|
|
|
56
61
|
# Test 4: add_agent dedups across spellings; canonicalization block yields one entry per persona
|
|
57
|
-
ADD=$(awk '/^add_agent\(\) \{/{on=1} on{print} on && /^\}/{exit}' "$
|
|
58
|
-
if [ -n "$ADD" ]; then ok; else bad "add_agent not found in
|
|
62
|
+
ADD=$(awk '/^add_agent\(\) \{/{on=1} on{print} on && /^\}/{exit}' "$ROSTER")
|
|
63
|
+
if [ -n "$ADD" ]; then ok; else bad "add_agent not found in scripts/review-roster.sh"; fi
|
|
59
64
|
eval "$ADD"
|
|
60
65
|
SELECTED_AGENTS="Security INFRA SCRAPING"
|
|
61
66
|
add_agent "Infrastructure"; add_agent "Scraping"; add_agent "Auth"; add_agent "AUTH"; add_agent "security"
|
|
@@ -93,5 +93,20 @@ for p in infra security scraper; do
|
|
|
93
93
|
if [ "$reads" -ge 1 ] && [ "$reads" = "$decls" ]; then ok; else bad "$p.md: $reads fences read SLICE_FILE, $decls re-declare it"; fi
|
|
94
94
|
done
|
|
95
95
|
|
|
96
|
+
# Staging slice path: every placeholder must be bound, and an unresolved SHA must yield a slash-free component.
|
|
97
|
+
STAGING_SPEC="$ROOT/commands/review-pr-staging.md"
|
|
98
|
+
if grep -qE '^REVIEW_SHA_SHORT=.*tr -cd .*unresolved' "$STAGING_SPEC"; then ok; else bad "staging spec does not define a hex-sanitized REVIEW_SHA_SHORT with an unresolved fallback"; fi
|
|
99
|
+
if grep -qF 'Phase -1 `REVIEW_SHA_SHORT`' "$STAGING_SPEC"; then ok; else bad "staging dispatch text does not bind [REVIEW_SHA_SHORT] to Phase -1 REVIEW_SHA_SHORT"; fi
|
|
100
|
+
SHORT_LINE=$(grep -E '^REVIEW_SHA_SHORT=' "$STAGING_SPEC" | head -n 1)
|
|
101
|
+
for full in "" "n/x" "0123456789abcdef0123456789abcdef01234567"; do
|
|
102
|
+
got=$(REVIEW_SHA_FULL="$full"; eval "$SHORT_LINE"; printf '%s' "$REVIEW_SHA_SHORT")
|
|
103
|
+
case "$full" in
|
|
104
|
+
0123*) want="0123456" ;;
|
|
105
|
+
*) want="unresolved" ;;
|
|
106
|
+
esac
|
|
107
|
+
if [ "$got" = "$want" ]; then ok; else bad "REVIEW_SHA_SHORT for '$full' = '$got' (want '$want')"; fi
|
|
108
|
+
case "$got" in */*) bad "REVIEW_SHA_SHORT contains a slash for '$full'" ;; *) ok ;; esac
|
|
109
|
+
done
|
|
110
|
+
|
|
96
111
|
echo "review-pr-gate2.test.sh: passed=$PASS failed=$FAILN"
|
|
97
112
|
[ "$FAILN" -eq 0 ]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-pr-staging-finding-filter.test.sh — fixtures for the anchored open-finding filter (forge#3800).
|
|
3
|
+
# The spec's `# FINDING_PR_FILTER` line is extracted and run against issue shapes: the gate must keep a
|
|
4
|
+
# hit only when its title suffix or `**Source**:` line names exactly PR #N; hidden markers never count.
|
|
5
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
6
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
9
|
+
PASS=0; FAILN=0
|
|
10
|
+
ok() { PASS=$((PASS+1)); }
|
|
11
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
|
|
12
|
+
command -v jq >/dev/null 2>&1 || { echo "review-pr-staging-finding-filter.test.sh: jq missing, skipped"; exit 0; }
|
|
13
|
+
|
|
14
|
+
extract() { grep -E '# FINDING_PR_FILTER$' "$1" | sed 's/[[:space:]]*# FINDING_PR_FILTER$//; s/^[[:space:]]*//'; }
|
|
15
|
+
SPEC="$ROOT/commands/review-pr-staging.md"
|
|
16
|
+
DEF="$(extract "$SPEC" | sort -u)"
|
|
17
|
+
[ "$(printf '%s\n' "$DEF" | grep -c .)" -eq 1 ] && ok || bad "review-pr-staging.md must carry one identical FINDING_PR_FILTER definition"
|
|
18
|
+
[ "$(extract "$ROOT/commands/deploy-info.md" | sort -u)" = "$DEF" ] && ok || bad "deploy-info.md filter differs from review-pr-staging.md"
|
|
19
|
+
[ "$(grep -c -F 'FINDING_PR_FILTER' "$SPEC")" -ge 3 ] && ok || bad "filter must be referenced by all three queries"
|
|
20
|
+
grep -q -F 'OVERRIDE: shipping with open findings' "$SPEC" && ok || bad "OVERRIDE text missing"
|
|
21
|
+
eval "$DEF"
|
|
22
|
+
|
|
23
|
+
run() { printf '%s' "$1" | jq -r --arg n "$2" "$FINDING_PR_FILTER"; }
|
|
24
|
+
J() { jq -n --arg t "$1" --arg b "$2" --argjson n "${3:-1}" '[{number:$n,title:$t,body:$b}]'; }
|
|
25
|
+
|
|
26
|
+
# #3605 shape: number only inside a hidden issue-create-token
|
|
27
|
+
OUT=$(run "$(J 'Some finding (review finding — PR #3593)' $'**Source**: PR #3593 — x\n\n<!-- issue-create-token:forge-create-20261010T165826Z-1996537-3776 -->' 3605)" 3776)
|
|
28
|
+
[ -z "$OUT" ] && ok || bad "issue-create-token ending in 3776 must not match"
|
|
29
|
+
# bare mention in prose
|
|
30
|
+
OUT=$(run "$(J 'Unrelated' 'see PR #3776 for context')" 3776); [ -z "$OUT" ] && ok || bad "prose mention must not match"
|
|
31
|
+
# longer number
|
|
32
|
+
OUT=$(run "$(J 'F (review finding — PR #37761)' $'**Source**: PR #37761 — x')" 3776); [ -z "$OUT" ] && ok || bad "PR #37761 must not match PR #3776"
|
|
33
|
+
OUT=$(run "$(J 'F (review finding — PR #3776)' 'b')" 37761); [ -z "$OUT" ] && ok || bad "PR #3776 must not match PR #37761"
|
|
34
|
+
# real findings still kept
|
|
35
|
+
OUT=$(run "$(J 'Bad thing (review finding — PR #3776)' 'no source line' 42)" 3776)
|
|
36
|
+
[ "$OUT" = " - #42: Bad thing (review finding — PR #3776)" ] && ok || bad "title-suffix finding must be kept in the ' - #N: title' format (got: $OUT)"
|
|
37
|
+
OUT=$(run "$(J 'Bad thing' $'intro\n**Source**: PR #3776 — Some title\nmore' 43)" 3776)
|
|
38
|
+
[ "$OUT" = " - #43: Bad thing" ] && ok || bad "Source-line finding must be kept"
|
|
39
|
+
OUT=$(run "$(J 'Bad thing' '**Source**: PR #3776' 44)" 3776); [ -n "$OUT" ] && ok || bad "Source line at end of body must be kept"
|
|
40
|
+
OUT=$(run "$(J 'Bad thing' null 45)" 3776); [ -z "$OUT" ] && ok || bad "body text 'null' must not match"
|
|
41
|
+
OUT=$(run '[{"number":1,"title":"x (review finding — PR #3776)","body":null}]' 3776); [ -n "$OUT" ] && ok || bad "null body must not break title match"
|
|
42
|
+
|
|
43
|
+
echo "review-pr-staging-finding-filter.test.sh: $PASS passed, $FAILN failed"
|
|
44
|
+
[ "$FAILN" -eq 0 ]
|