forgedock 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +2 -0
  2. package/bin/engine/phases.mjs +4 -2
  3. package/bin/engine/telemetry-seam.mjs +1 -1
  4. package/bin/engine-cli.mjs +40 -1
  5. package/bin/engine.mjs +48 -3
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/lean/effects.mjs +51 -0
  8. package/bin/lean/lean.mjs +352 -0
  9. package/bin/lean/lean.test.mjs +525 -0
  10. package/bin/lean/prompts.mjs +62 -0
  11. package/bin/lean/risk.mjs +49 -0
  12. package/bin/runner.mjs +99 -6
  13. package/commands/orchestrate/phase-4-execution.md +1 -1
  14. package/commands/review-pr-agents/protocols.md +19 -0
  15. package/commands/review-pr.md +7 -6
  16. package/commands/work-on/build/architect.md +1 -1
  17. package/commands/work-on/build/implement.md +9 -1
  18. package/commands/work-on/build/validate.md +14 -7
  19. package/commands/work-on/build.md +118 -8
  20. package/commands/work-on/close.md +9 -4
  21. package/commands/work-on/remediate.md +11 -4
  22. package/commands/work-on/review.md +12 -4
  23. package/commands/work-on.md +4 -4
  24. package/docs/CONFIG.md +8 -1
  25. package/docs/RELEASE-NOTES.md +21 -0
  26. package/docs/TELEMETRY.md +2 -2
  27. package/docs/WORK-ON-RUNTIME.md +11 -3
  28. package/package.json +2 -2
  29. package/scripts/architect-resume-match.test.sh +12 -3
  30. package/scripts/build-knowledge-index.mjs +8 -5
  31. package/scripts/builder-resume-match.test.sh +12 -3
  32. package/scripts/calibration.mjs +5 -2
  33. package/scripts/check-spec-markers.sh +2 -0
  34. package/scripts/ci-local/lib.mjs +11 -3
  35. package/scripts/ci-local/run.mjs +155 -49
  36. package/scripts/ci-local/scoping.mjs +27 -4
  37. package/scripts/ci-local.sh +3 -2
  38. package/scripts/ci-local.test.sh +189 -0
  39. package/scripts/classify-finding.sh +1 -1
  40. package/scripts/file-deferred-followups.sh +91 -30
  41. package/scripts/file-deferred-followups.test.sh +101 -8
  42. package/scripts/lib/telemetry.sh +2 -0
  43. package/scripts/review-finalize.sh +101 -12
  44. package/scripts/review-finalize.test.sh +136 -5
  45. package/scripts/review-roster.sh +18 -5
  46. package/scripts/review-roster.test.sh +57 -1
  47. package/scripts/selfreview-plan.sh +86 -0
  48. package/scripts/selfreview-plan.test.sh +90 -0
  49. package/scripts/spawn-depth-check.sh +5 -4
  50. package/scripts/spawn-depth-check.test.sh +3 -0
  51. package/scripts/telemetry.test.sh +8 -0
  52. package/scripts/transition-label.sh +26 -0
  53. package/scripts/verify-phase-trail.test.sh +6 -0
@@ -915,5 +915,194 @@ OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch ma
915
915
  eq "FIX-3: the run passes with the job deferred" 0 "$RC"
916
916
  hasnt "FIX-3: no container was started" "$(cat "$FAKE_DOCKER_LOG")" "run -d"
917
917
 
918
+ # ------------------------------------------------------------------------------------------------ forge#4057: concurrency
919
+ # SEC-3 universe: a single-test step is judged against the repo-wide tests, not the one test it runs
920
+ r=$(blank); mkdir -p "$r/scripts"
921
+ echo 'echo a' > "$r/scripts/a.sh"; echo 'bash scripts/a.sh' > "$r/scripts/a.test.sh"
922
+ echo 'echo x' > "$r/scripts/x.test.sh"; echo 'echo z' > "$r/scripts/zz.sh"
923
+ commit_base "$r"
924
+ _scope() { # _scope <command> <changed files...>
925
+ node -e "
926
+ import('$ROOT/scripts/ci-local/scoping.mjs').then(m=>{const [cmd,...c]=process.argv.slice(1);const s=m.makeScopeCtx({repo:'$r',baseRef:'main',from:'main',changed:c});
927
+ const r=m.scopeCommand(cmd,'',s);console.log(JSON.stringify({action:r.action,widen:r.widen_reason||''}))})" "$@"; }
928
+ echo '# c' >> "$r/scripts/a.sh"
929
+ R=$(_scope 'bash scripts/x.test.sh' scripts/a.sh)
930
+ eq "4057: an unrelated x.test.sh is skipped when a.sh has a sibling a.test.sh" skip "$(printf '%s' "$R" | jq -r '.action')" # edge_e2_unrelated_test_skipped
931
+ R=$(_scope 'bash scripts/a.test.sh' scripts/a.sh)
932
+ eq "4057: the sibling a.test.sh still runs" run "$(printf '%s' "$R" | jq -r '.action')"
933
+ echo '# c' >> "$r/scripts/zz.sh"
934
+ R=$(_scope 'bash scripts/x.test.sh' scripts/zz.sh)
935
+ has "4057: SEC-3 widen still fires when a changed source reaches no test anywhere" "$(printf '%s' "$R" | jq -r '.widen')" "empty-selection" # edge_e1_sec3_widen_still_fires
936
+ eq "4057: ...and the step runs" run "$(printf '%s' "$R" | jq -r '.action')"
937
+ R=$(_scope 'bash scripts/x.test.sh' scripts/a.sh scripts/zz.sh)
938
+ has "4057: one unreached source file among several still widens a single-test step" "$(printf '%s' "$R" | jq -r '.widen')" "scripts/zz.sh"
939
+ # SEC-3 universe is per ecosystem: a test in another language never covers a changed source (polyglot under-block)
940
+ r=$(blank); mkdir -p "$r/pkg" "$r/tests"
941
+ echo 'def f(): return 1' > "$r/pkg/utils.py"; echo 'def test_core(): assert True' > "$r/tests/test_core.py"
942
+ echo 'test("u", () => {})' > "$r/tests/utils.test.ts"
943
+ commit_base "$r"
944
+ echo '# c' >> "$r/pkg/utils.py"
945
+ R=$(_scope 'pytest tests/test_core.py' pkg/utils.py)
946
+ eq "SEC-3 ecosystem: a pytest step is not skipped because an unrelated .ts test matches the changed .py" run "$(printf '%s' "$R" | jq -r '.action')"
947
+ has "SEC-3 ecosystem: ...it widens and names the unreached source" "$(printf '%s' "$R" | jq -r '.widen')" "pkg/utils.py"
948
+ R=$(_scope 'pytest' pkg/utils.py)
949
+ eq "SEC-3 ecosystem: a bare pytest runner step widens too" run "$(printf '%s' "$R" | jq -r '.action')"
950
+ R=$(_scope 'python3 tests/test_core.py' pkg/utils.py)
951
+ eq "SEC-3 ecosystem: a single python test step widens (no py test reaches the change)" run "$(printf '%s' "$R" | jq -r '.action')"
952
+ # DRR-1: a single-language change never widens another ecosystem's runner step (it skips/narrows as at a0d45ae)
953
+ r=$(blank); mkdir -p "$r/pkg" "$r/tests"
954
+ echo 'def f(): return 1' > "$r/pkg/utils.py"; printf 'from pkg import utils\ndef test_core(): assert utils.f()\n' > "$r/tests/test_core.py"
955
+ echo 'import test from "node:test"; test("a", () => {})' > "$r/tests/a.test.mjs"
956
+ commit_base "$r"
957
+ echo '# c' >> "$r/pkg/utils.py"
958
+ R=$(_scope 'node --test tests/a.test.mjs' pkg/utils.py)
959
+ eq "DRR-1: a node step is not widened by a Python-only change" skip "$(printf '%s' "$R" | jq -r '.action')"
960
+ eq "DRR-1: ...and carries no widen reason" "" "$(printf '%s' "$R" | jq -r '.widen')"
961
+ R=$(_scope 'pytest tests/test_core.py' pkg/utils.py)
962
+ eq "DRR-1: the pytest step still runs the reaching test" run "$(printf '%s' "$R" | jq -r '.action')"
963
+ eq "DRR-1: ...scoped, not widened" "" "$(printf '%s' "$R" | jq -r '.widen')"
964
+
965
+ # FIFO slots (tickets), dead-owner reaping, deadline while queued
966
+ _slot() { FORGE_CI_LOCAL_HOME="$SLOT_HOME" FORGE_CI_LOCAL_SLOTS=1 node --input-type=module -e "
967
+ import('$ROOT/scripts/ci-local/run.mjs').then(async (m) => {
968
+ const [id, hold, budget] = process.argv.slice(1).map(Number);
969
+ const t0 = Date.now();
970
+ const rel = await m.acquireSlot('t' + id, t0 + budget);
971
+ if (!rel) { console.log('timeout ' + id); return; }
972
+ console.log('acq ' + id + (process.env.SLOT_SHOW_WAIT === '1' ? ' wait=' + (Date.now() - t0) : '')); await new Promise((r) => setTimeout(r, hold)); rel();
973
+ })" "$@"; }
974
+ SLOT_HOME="$T/fifo-home"; mkdir -p "$SLOT_HOME"; LOG="$T/fifo.log"; : > "$LOG"
975
+ # hold (1500ms) exceeds the cumulative spawn spacing (3 x 0.3s), so invocations 2-4 all queue behind invocation 1
976
+ for i in 1 2 3 4; do SLOT_SHOW_WAIT=1 _slot "$i" 1500 30000 >> "$LOG" 2>&1 & sleep 0.3; done; wait
977
+ eq "4057: 4 invocations, 1 slot: acquired strictly in ticket order, none starved" "acq 1 acq 2 acq 3 acq 4" "$(sed 's/ wait=.*//' "$LOG" | tr '\n' ' ' | sed 's/ $//')" # edge_e4_fifo_order
978
+ W1=$(sed -n 's/^acq 1 wait=//p' "$LOG")
979
+ case "$W1" in ''|*[!0-9]*) bad "4059: invocation 1 wait not recorded ('$W1')" ;; *) [ "$W1" -lt 500 ] && ok || bad "4059: invocation 1 acquired immediately (wait ${W1}ms, want < 500)" ;; esac
980
+ for i in 2 3 4; do # edge_e2_queued_waits
981
+ Wi=$(sed -n "s/^acq $i wait=//p" "$LOG")
982
+ case "$Wi" in ''|*[!0-9]*) bad "4059: invocation $i wait not recorded ('$Wi')" ;; *) [ "$Wi" -ge 500 ] && ok || bad "4059: invocation $i queued behind the holder (wait ${Wi}ms, want >= 500)" ;; esac
983
+ done
984
+ eq "4057: no ticket is left behind" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
985
+ SLOT_HOME="$T/fifo-home2"; mkdir -p "$SLOT_HOME/ci-local.lock/slot-0" "$SLOT_HOME/ci-local.lock/queue"
986
+ ( : ) & DEAD=$!; wait "$DEAD" 2>/dev/null
987
+ printf '%s 1 ghost\n' "$DEAD" > "$SLOT_HOME/ci-local.lock/slot-0/owner"
988
+ : > "$SLOT_HOME/ci-local.lock/queue/00000000000001-000001-$DEAD-abcdef"
989
+ eq "4057: a dead slot owner and a dead earlier ticket are reaped, the queue is not blocked" "acq 9" "$(_slot 9 0 5000)" # edge_e3_dead_owner_reaped
990
+ eq "4057: no ticket left after a reaped acquire" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
991
+ mkdir -p "$SLOT_HOME/ci-local.lock/slot-0"; printf '%s %s000 live\n' "$$" "$(date +%s)" > "$SLOT_HOME/ci-local.lock/slot-0/owner"
992
+ eq "4057: the deadline while queued returns no slot" "timeout 8" "$(_slot 8 0 700)" # edge_e6_deadline_ticket_removed
993
+ eq "4057: ...and removes its ticket (no leak)" 0 "$(ls "$SLOT_HOME/ci-local.lock/queue" | wc -l | tr -d ' ')"
994
+
995
+ # single-flight dedup: identical check in two jobs runs once; a different env must not dedup
996
+ r=$(blank); mkdir -p "$r/.github/workflows"
997
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
998
+ on: pull_request
999
+ jobs:
1000
+ a:
1001
+ runs-on: ubuntu-latest
1002
+ steps:
1003
+ - run: echo x >> counter.txt; sleep 1
1004
+ b:
1005
+ runs-on: ubuntu-latest
1006
+ steps:
1007
+ - run: echo x >> counter.txt; sleep 1
1008
+ c:
1009
+ runs-on: ubuntu-latest
1010
+ env:
1011
+ VARIANT: "1"
1012
+ steps:
1013
+ - run: echo x >> counter.txt; sleep 1
1014
+ YML
1015
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1016
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1017
+ eq "4057: dedup run passes" 0 "$RC"
1018
+ eq "4057: identical step ran once, the different-env step ran too (2 executions of 3 jobs)" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e8_different_env_not_deduped
1019
+ has "4057: the duplicate job reports dedup" "$OUT" "dedup=1"
1020
+ has "4057: the result line carries the dedup count and wait_ms" "$OUT" "wait_ms="
1021
+ r=$(blank); mkdir -p "$r/.github/workflows"
1022
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
1023
+ on: pull_request
1024
+ jobs:
1025
+ a:
1026
+ runs-on: ubuntu-latest
1027
+ steps:
1028
+ - run: echo y >> fail.txt; sleep 1; exit 3
1029
+ b:
1030
+ runs-on: ubuntu-latest
1031
+ steps:
1032
+ - run: echo y >> fail.txt; sleep 1; exit 3
1033
+ YML
1034
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1035
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1036
+ eq "4057: a failing check shared by two jobs fails the gate" 1 "$RC"
1037
+ eq "4057: ...and was executed once, not re-run by the duplicate" 1 "$(wc -l < "$r/fail.txt" | tr -d ' ')" # edge_e5_failure_shared_once
1038
+ eq "4057: ...both jobs report the failure" 2 "$(printf '%s\n' "$OUT" | grep -c '^CI_LOCAL_JOB: fail ')"
1039
+
1040
+ # forge#4060: jobs with the same command but different service containers must not share a dedup result or a cached pass
1041
+ mksvc() { # $1 = repo, $2 = image of job b's postgres, $3 = job c's second-service ordering flag
1042
+ mkdir -p "$1/.github/workflows"
1043
+ cat > "$1/.github/workflows/ci.yml" <<YML
1044
+ on: pull_request
1045
+ jobs:
1046
+ a:
1047
+ runs-on: ubuntu-latest
1048
+ services:
1049
+ postgres:
1050
+ image: postgres:14
1051
+ steps:
1052
+ - run: echo x >> counter.txt; sleep 1
1053
+ b:
1054
+ runs-on: ubuntu-latest
1055
+ services:
1056
+ postgres:
1057
+ image: $2
1058
+ steps:
1059
+ - run: echo x >> counter.txt; sleep 1
1060
+ YML
1061
+ }
1062
+ r=$(blank); mksvc "$r" postgres:16
1063
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1064
+ : > "$FAKE_DOCKER_LOG"
1065
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1066
+ eq "4060: different-services run passes" 0 "$RC"
1067
+ eq "4060: same command with different services ran in both jobs, no shared flight result" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e4_different_services_not_deduped
1068
+ hasnt "4060: no job reports dedup" "$OUT" "dedup=1"
1069
+ r=$(blank); mksvc "$r" postgres:14
1070
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1071
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
1072
+ eq "4060: identical services still dedup (one execution)" 1 "$(wc -l < "$r/counter.txt" | tr -d ' ')"
1073
+ has "4060: the identical-services duplicate reports dedup" "$OUT" "dedup=1"
1074
+ # persisted cache: a pass for services X is not a hit for services Y
1075
+ r=$(blank); mksvc "$r" postgres:16
1076
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1077
+ OUT=$(PATH="$T/bin:$PATH" FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=2 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 1 2>&1)
1078
+ eq "4060: persisted cache is not shared across different service sets" 2 "$(wc -l < "$r/counter.txt" | tr -d ' ')" # edge_e5_cache_not_shared
1079
+
1080
+ # wait_ms is reported apart from duration; cached jobs wait for nothing; cheap jobs go first once durations are known
1081
+ r=$(blank); mkdir -p "$r/.github/workflows"
1082
+ cat > "$r/.github/workflows/ci.yml" <<'YML'
1083
+ on: pull_request
1084
+ jobs:
1085
+ big:
1086
+ runs-on: ubuntu-latest
1087
+ steps:
1088
+ - run: sleep 1.5 # ord-big
1089
+ small:
1090
+ runs-on: ubuntu-latest
1091
+ steps:
1092
+ - run: echo ord-small
1093
+ YML
1094
+ echo s > "$r/s.txt"; commit_base "$r"; echo x >> "$r/s.txt"
1095
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 2 2>&1); RC=$?
1096
+ eq "4057: wait run passes" 0 "$RC"
1097
+ WM=$(printf '%s\n' "$OUT" | sed -n 's/^CI_LOCAL_RESULT:.* wait_ms=\([0-9]*\).*/\1/p')
1098
+ [ "${WM:-0}" -ge 500 ] && ok || bad "4057: one job queued for the single slot, so wait_ms is reported (got '${WM:-}')" "$OUT"
1099
+ has "4057: job lines carry wait=" "$OUT" " wait="
1100
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=0 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 2 2>&1)
1101
+ has "4057: a fully cached job waits for no slot (wait=0.0s)" "$OUT" "cached=1 wait=0.0s" # edge_e7_cached_job_no_wait
1102
+ OUT=$(FORGE_CI_LOCAL_NO_CACHE=1 FORGE_CI_LOCAL_SLOTS=1 bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat --max-parallel 1 2>&1)
1103
+ FIRST=$(printf '%s\n' "$OUT" | grep '^CI_LOCAL_JOB: pass' | head -n 1)
1104
+ has "4057: the cheapest known job runs first (small before big)" "$FIRST" "gha:ci:small"
1105
+ FORGE_TELEMETRY=off bash -c 'grep -q "wait_ms" "$0"' "$S" && ok || bad "4057: ci-local.sh forwards wait_ms to telemetry"
1106
+
918
1107
  echo "ci-local.test.sh: $pass passed, $fail failed"
919
1108
  [ "$fail" -eq 0 ]
@@ -56,7 +56,7 @@
56
56
  # lists the deferred issue numbers confirmed open, one per line; a deferred item
57
57
  # demotes only when its number is listed (closed, unreadable or absent: no demotion).
58
58
  # The caller lists a number only after verifying it is an open issue (not a PR) whose
59
- # body carries `FORGE:DEFERRED_FROM: #<merge issue>` and the path.
59
+ # body carries `FORGE:DEFERRED_FROM: #<merge issue>` and a checklist line for the exact path.
60
60
  # `--merge-issue <N>` is the PR's linked issue: `deferred → #N` never demotes (that
61
61
  # issue is always open during review, so it is no follow-up).
62
62
  # Never demoted: CRITICAL/HIGH, `not-affected` items, safety-exempt findings
@@ -4,8 +4,9 @@
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
6
6
  # The builder decides fold-vs-defer (judgment, build.md B2.0); this script does the mechanics for the items
7
- # that stay deferred: trusted lookup of an open follow-up carrying <!-- FORGE:DEFERRED_FROM: #N -->, append the
8
- # missing items to it as checklist entries (exact path match), or create ONE issue with a checklist.
7
+ # that stay deferred: trusted lookup (claim marker <!-- FORGE:DEFERRED_ISSUE: #M --> on the parent, then a state-all search) of a follow-up carrying
8
+ # <!-- FORGE:DEFERRED_FROM: #N -->, append the missing items (exact path match; a closed one is reopened), or create ONE issue
9
+ # with a checklist, claim it on the parent and re-check once for a concurrent lower-numbered duplicate.
9
10
  #
10
11
  # Usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]
11
12
  # items TSV, one per line: <path> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty>
@@ -71,27 +72,60 @@ finish() { # $1 = batched_into number or empty, $2 = filed count
71
72
  }
72
73
 
73
74
  # --- Trusted lookup (author predicate applied before path match), one retry ---------------------------------
74
- do_lookup() {
75
+ # Sources, in order: (1) claim markers `<!-- FORGE:DEFERRED_ISSUE: #M -->` on the parent's REST comments (read-after-write
76
+ # consistent), (2) a search over ALL issue states (eventually consistent, so only a fallback). Every candidate is then
77
+ # verified by number over REST (trusted author, DEFERRED_FROM marker, not a PR); the lowest number is canonical.
78
+ JQ_PRE='def lst: split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0));
79
+ def trusted: ((.author_association // "") as $x | ($assoc | lst) | index($x) != null)
80
+ or ((.user.type // "") == "Bot")
81
+ or ((.user.login // "") as $x | ($logins | lst) | index($x) != null);'
82
+ CLAIM_RE='^\s*<!-- FORGE:DEFERRED_ISSUE: #(?<n>[0-9]+) -->'
83
+ claim_nums() {
84
+ gh api --paginate "repos/${REPO}/issues/${PARENT}/comments" 2>/dev/null \
85
+ | jq -s -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg re "$CLAIM_RE" "$JQ_PRE"'
86
+ [.[][] | select(trusted) | (.body // "") | capture($re) | .n | tonumber] | unique | .[]' 2>/dev/null
87
+ }
88
+ search_nums() {
75
89
  gh api -X GET search/issues -f per_page=50 \
76
- -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue is:open" 2>/dev/null \
77
- | jq -c --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" '
78
- ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
79
- | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
80
- | [.items[]
81
- | select(((.author_association // "") as $x | $A | index($x) != null)
82
- or ((.user.type // "") == "Bot")
83
- or ((.user.login // "") as $x | $L | index($x) != null))
84
- | select((.body // "") | contains($m))
85
- | {number, body: (.body // "")}]
86
- | sort_by(.number)' 2>/dev/null
90
+ -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue" 2>/dev/null \
91
+ | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
92
+ [.items[] | select(trusted) | select((.body // "") | contains($m)) | .number] | sort | .[]' 2>/dev/null
93
+ }
94
+ verify_num() { # number -> `number<TAB>state` when a trusted, non-PR issue carries the marker; empty when not (a 404 is "not it")
95
+ local out rc
96
+ out=$(gh api "repos/${REPO}/issues/$1" 2>"$TMP/verr"); rc=$?
97
+ if [ "$rc" -ne 0 ]; then grep -qE '404|Not Found|410' "$TMP/verr" 2>/dev/null && return 0; return 1; fi
98
+ printf '%s' "$out" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" "$JQ_PRE"'
99
+ select(.pull_request == null) | select(trusted) | select((.body // "") | contains($m))
100
+ | "\(.number)\t\(.state)"' 2>/dev/null
101
+ }
102
+ CANON=""; CSTATE=""
103
+ pick_verified() { # numbers... ; sets CANON/CSTATE from the first verified one; non-zero only when a fetch failed
104
+ local n r
105
+ for n in "$@"; do
106
+ r=$(verify_num "$n") || return 1
107
+ if [ -n "$r" ]; then CANON="${r%%$'\t'*}"; CSTATE="${r#*$'\t'}"; return 0; fi
108
+ done
109
+ return 0
87
110
  }
88
- LOOKUP=""
111
+ find_canonical() {
112
+ local cands
113
+ CANON=""; CSTATE=""
114
+ cands=$(claim_nums) || return 1
115
+ # shellcheck disable=SC2086
116
+ pick_verified $cands || return 1
117
+ [ -n "$CANON" ] && return 0
118
+ cands=$(search_nums) || return 1
119
+ # shellcheck disable=SC2086
120
+ pick_verified $cands || return 1
121
+ return 0
122
+ }
123
+ LOOKUP_OK=""
89
124
  for attempt in 1 2; do
90
- LOOKUP=$(do_lookup) && [ -n "$LOOKUP" ] && break
91
- LOOKUP=""
125
+ if find_canonical; then LOOKUP_OK=1; break; fi
92
126
  [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
93
127
  done
94
- if [ -z "$LOOKUP" ]; then
128
+ if [ -z "$LOOKUP_OK" ]; then
95
129
  echo "DEFERRED_LOOKUP=FAILED"
96
130
  exit 3
97
131
  fi
@@ -108,27 +142,35 @@ has_entry() { # body-file path : exact checklist-line match, not substring
108
142
  grep -F -- "\`$2\`" "$1" | grep -qE "^- \[[ xX]\] \`$(printf '%s' "$2" | sed 's/[][\.*^$+?(){}|]/\\&/g')\`( |\$)"
109
143
  }
110
144
 
111
- NUM=$(printf '%s' "$LOOKUP" | jq -r 'first | .number // empty' 2>/dev/null)
112
-
113
- if [ -n "$NUM" ]; then
145
+ # Append the missing items to follow-up $1 (state $2), then reopen it when closed (review-finalize needs it open).
146
+ append_items() { # number state ; non-zero = failure
147
+ local num="$1" state="$2" note=""
114
148
  # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
115
- if ! gh api "repos/${REPO}/issues/${NUM}" --jq '.body // ""' > "$TMP/body" 2>/dev/null; then
116
- echo "DEFERRED_FILE=FAILED"; exit 4
117
- fi
149
+ gh api "repos/${REPO}/issues/${num}" --jq '.body // ""' > "$TMP/body" 2>/dev/null || return 1
118
150
  : > "$TMP/add"
119
151
  while IFS="$SEP" read -r p w k; do
120
152
  has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
121
153
  done < "$TMP/items"
154
+ [ "$state" = "closed" ] && note=" (follow-up was closed; reopened)"
122
155
  if [ -s "$TMP/add" ]; then
123
- { cat "$TMP/body"; printf '\n### Added by a later build of #%s\n\n' "$PARENT"; cat "$TMP/add"; } > "$TMP/newbody"
156
+ { cat "$TMP/body"; printf '\n### Added by a later build of #%s%s\n\n' "$PARENT" "$note"; cat "$TMP/add"; } > "$TMP/newbody"
124
157
  if [ -n "${DRY_RUN:-}" ]; then
125
- echo "DRY_RUN: gh issue edit $NUM (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
126
- elif ! gh issue edit "$NUM" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1; then
127
- echo "DEFERRED_FILE=FAILED"; exit 4
158
+ echo "DRY_RUN: gh issue edit $num (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
159
+ else
160
+ gh issue edit "$num" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1 || return 1
128
161
  fi
129
162
  fi
130
- while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
131
- finish "$NUM" 0
163
+ if [ "$state" = "closed" ]; then
164
+ if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: gh issue reopen $num" >&2
165
+ else gh issue reopen "$num" -R "$REPO" >/dev/null 2>&1 || return 1; fi
166
+ fi
167
+ return 0
168
+ }
169
+
170
+ if [ -n "$CANON" ]; then
171
+ append_items "$CANON" "$CSTATE" || { echo "DEFERRED_FILE=FAILED"; exit 4; }
172
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$CANON"; done < "$TMP/items"
173
+ finish "$CANON" 0
132
174
  exit 0
133
175
  fi
134
176
 
@@ -154,6 +196,25 @@ else
154
196
  NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
155
197
  [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
156
198
  fi
199
+ if [ -z "${DRY_RUN:-}" ]; then
200
+ # Claim the follow-up on the parent (REST comments are read-after-write consistent; best effort, the search still finds it).
201
+ gh issue comment "$PARENT" -R "$REPO" --body "<!-- FORGE:DEFERRED_ISSUE: #${NUM} -->" >/dev/null 2>&1 \
202
+ || echo "file-deferred-followups: warning: could not post claim marker for #${NUM} on #${PARENT}" >&2
203
+ # Post-create re-check: a concurrent build may have filed first. The lowest trusted number is canonical; the newer one is closed.
204
+ if find_canonical; then
205
+ if [ -n "$CANON" ] && [ "$CANON" -lt "$NUM" ]; then
206
+ if append_items "$CANON" "$CSTATE"; then
207
+ gh issue close "$NUM" -R "$REPO" --reason "not planned" --comment "Duplicate of #${CANON}: deferred items of #${PARENT} are tracked there." >/dev/null 2>&1 \
208
+ || echo "file-deferred-followups: warning: could not close duplicate #${NUM}" >&2
209
+ NUM="$CANON"
210
+ else
211
+ echo "file-deferred-followups: warning: could not move items to #${CANON}; keeping #${NUM}" >&2
212
+ fi
213
+ fi
214
+ else
215
+ echo "file-deferred-followups: warning: post-create re-check failed; keeping #${NUM}" >&2
216
+ fi
217
+ fi
157
218
  while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
158
219
  finish "$NUM" 1
159
220
  exit 0
@@ -8,19 +8,27 @@ S="$HERE/file-deferred-followups.sh"
8
8
  T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
9
9
  mkdir "$T/bin"
10
10
  export FORGE_DEFERRED_RETRY_SLEEP=0
11
- export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body"
11
+ export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body" COMMENTS_JSON="$T/comments.json" T_DIR="$T"
12
12
 
13
13
  cat > "$T/bin/gh" <<'MOCK'
14
14
  #!/usr/bin/env bash
15
15
  echo "$*" >> "$GH_LOG"
16
16
  case "$1 $2" in
17
17
  "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
+ "api --paginate") [ -n "${CLAIM_FAIL:-}" ] && exit 1; cat "$COMMENTS_JSON"; exit 0 ;;
18
19
  "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
20
+ "issue comment") [ -n "${COMMENT_FAIL:-}" ] && exit 1; exit 0 ;;
21
+ "issue reopen") [ -n "${REOPEN_FAIL:-}" ] && exit 1; exit 0 ;;
22
+ "issue close") [ -n "${CLOSE_FAIL:-}" ] && exit 1; exit 0 ;;
19
23
  "issue edit")
20
24
  [ -n "${EDIT_FAIL:-}" ] && exit 1
21
25
  while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
22
26
  esac
23
- case "$1" in api) cat "$ISSUE_BODY"; exit 0 ;; esac
27
+ if [ "$1" = api ]; then
28
+ case "$*" in *--jq*) cat "$ISSUE_BODY"; exit 0 ;; esac
29
+ n="${2##*/}"; [ -f "$T_DIR/issue.$n.json" ] && { cat "$T_DIR/issue.$n.json"; exit 0; }
30
+ echo "HTTP 404: Not Found" >&2; exit 1
31
+ fi
24
32
  exit 1
25
33
  MOCK
26
34
  chmod +x "$T/bin/gh"; export PATH="$T/bin:$PATH"
@@ -29,7 +37,12 @@ PASS=0; FAIL=0
29
37
  ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
30
38
  bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
31
39
  check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
32
- reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL DRY_RUN; echo '{"items":[]}' > "$SEARCH_JSON"; }
40
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN
41
+ echo '{"items":[]}' > "$SEARCH_JSON"; echo '[]' > "$COMMENTS_JSON"; }
42
+ # issue_json NUMBER STATE ASSOCIATION [PARENT]: REST view of a follow-up carrying the DEFERRED_FROM marker
43
+ issue_json() { printf '{"number":%s,"state":"%s","author_association":"%s","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #%s -->"}\n' "$1" "$2" "$3" "${4:-10}" > "$T/issue.$1.json"; }
44
+ # claim_json ASSOCIATION NUMBER...: parent comments carrying claim markers
45
+ claim_json() { local a="$1" out="" n; shift; for n in "$@"; do out="${out:+$out,}{\"author_association\":\"$a\",\"user\":{\"type\":\"User\"},\"body\":\"<!-- FORGE:DEFERRED_ISSUE: #$n -->\"}"; done; echo "[$out]" > "$COMMENTS_JSON"; }
33
46
  printf 'a/x.sh\twhy a\t\nb/y.sh\twhy b\tknown-flaw\nc/z.sh\twhy c\t\n' > "$T/items.tsv"
34
47
 
35
48
  # 1. N items -> exactly one create
@@ -43,7 +56,7 @@ check "create: telemetry" "printf '%s' \"\$OUT\" | grep -q 'DEFERRED_TELEMETRY d
43
56
  # 2. append to existing trusted open issue; exact path match
44
57
  reset
45
58
  printf '## Affected Files\n- `a/x.sh`\n\n- [ ] `a/x.sh.bak` — other\n<!-- FORGE:DEFERRED_FROM: #10 -->\n' > "$ISSUE_BODY"
46
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
59
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
47
60
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
48
61
  check "append: rc 0, no create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG'"
49
62
  check "append: telemetry batched into 77 filed=0" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
@@ -55,7 +68,7 @@ reset
55
68
  printf 'd/w.sh\t\tknown-flaw\n' > "$T/emptywhy.tsv"
56
69
  bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv" >/dev/null; RC=$?
57
70
  rm -f "$ISSUE_BODY.edited"; printf 'x\n' > "$ISSUE_BODY"
58
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
71
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
59
72
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv"); RC=$?
60
73
  check "empty-why: append keeps known-flaw flag" "[ $RC -eq 0 ] && grep -q 'd/w.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
61
74
 
@@ -66,13 +79,13 @@ check "missing option value: rc 2" "[ $RC -eq 2 ]"
66
79
  # 3. already-listed path is not duplicated; nothing to add -> no edit
67
80
  reset
68
81
  printf -- '- [ ] `a/x.sh` — w\n- [x] `b/y.sh`\n- [ ] `c/z.sh`\n' > "$ISSUE_BODY"
69
- echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
82
+ echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
70
83
  bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
71
84
  check "idempotent: no edit, no create on resume" "[ $RC -eq 0 ] && ! grep -qE '^issue (edit|create)' '$GH_LOG'"
72
85
 
73
86
  # 4. untrusted match ignored -> new issue
74
87
  reset
75
- echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
88
+ echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 66 open NONE; issue_json 77 open OWNER
76
89
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv")
77
90
  check "untrusted: ignored, create filed" "grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
78
91
 
@@ -87,7 +100,7 @@ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
87
100
  check "create failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
88
101
  reset; export EDIT_FAIL=1
89
102
  printf 'x\n' > "$ISSUE_BODY"
90
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
103
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
91
104
  OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
92
105
  check "edit failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
93
106
 
@@ -102,5 +115,85 @@ OUT=$(printf '' | bash "$S" --parent 10 --repo o/r)
102
115
  check "empty: no create, deferred_items=0" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'deferred_items=0'"
103
116
  bash "$S" --repo o/r >/dev/null 2>&1; check "usage: missing parent rc 2" "[ $? -eq 2 ]"
104
117
 
118
+ # 9. e1: search index lags (returns nothing) but the claim marker on the parent finds the follow-up
119
+ reset
120
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 open OWNER; claim_json OWNER 77
121
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
122
+ check "index-lag: claim found, no duplicate create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
123
+ check "index-lag: search never needed" "! grep -q search/issues '$GH_LOG'"
124
+
125
+ # 10. e2: closed follow-up (found only by a state-all search) is reused and reopened
126
+ reset
127
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER
128
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
129
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
130
+ check "closed: reused, reopened, no create" "[ $RC -eq 0 ] && grep -q '^issue reopen 77' '$GH_LOG' && ! grep -q '^issue create' '$GH_LOG'"
131
+ check "closed: search query covers all states" "grep -q 'is:issue' '$GH_LOG' && ! grep 'api -X' '$GH_LOG' | grep -q 'is:open'"
132
+ reset; export REOPEN_FAIL=1
133
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER; claim_json OWNER 77
134
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
135
+ check "closed: reopen failure is rc 4" "[ $RC -eq 4 ]"
136
+
137
+ # 11. create posts the claim marker on the parent
138
+ reset
139
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
140
+ check "claim: posted on parent after create" "[ $RC -eq 0 ] && grep -q '^issue comment 10 .*FORGE:DEFERRED_ISSUE: #901' '$GH_LOG'"
141
+ reset; export COMMENT_FAIL=1
142
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
143
+ check "claim: post failure only warns" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#901 filed=1'"
144
+
145
+ # 12. e3: a concurrent build filed #800 first; the first claim read (before our create) misses it, the post-create re-check sees it
146
+ race_setup() { # first comments read returns [] (the other build has not claimed yet), later reads return the real listing
147
+ CALLS="$T/claim.calls"; : > "$CALLS"; export CALLS
148
+ mv "$T/bin/gh" "$T/bin/gh.real"
149
+ cat > "$T/bin/gh" <<'WRAP'
150
+ #!/usr/bin/env bash
151
+ if [ "$1 $2" = "api --paginate" ]; then
152
+ echo x >> "$CALLS"
153
+ if [ "$(wc -l < "$CALLS" | tr -d ' ')" -le 1 ]; then echo "$*" >> "$GH_LOG"; echo '[]'; exit 0; fi
154
+ fi
155
+ exec "$(dirname "$0")/gh.real" "$@"
156
+ WRAP
157
+ chmod +x "$T/bin/gh"
158
+ }
159
+ race_teardown() { mv "$T/bin/gh.real" "$T/bin/gh"; }
160
+ reset
161
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 800 open OWNER; claim_json OWNER 800
162
+ race_setup
163
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
164
+ race_teardown
165
+ check "race: newer duplicate closed, items moved to lowest" "[ $RC -eq 0 ] && grep -q '^issue close 901' '$GH_LOG' && grep -q '^issue edit 800' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'batched_into=#800 filed=1'"
166
+ check "race: rows point at canonical issue" "[ \$(printf '%s\n' \"\$OUT\" | grep -c '800\$') -ge 3 ]"
167
+ # e5: duplicate-close failure only warns
168
+ reset; export CLOSE_FAIL=1
169
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 800 open OWNER; claim_json OWNER 800
170
+ race_setup
171
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
172
+ race_teardown
173
+ check "race: duplicate-close failure still rc 0" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#800'"
174
+
175
+ # 13. e4: claim listing failure is a failed lookup, never "none found"
176
+ reset; export CLAIM_FAIL=1
177
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
178
+ check "claim-failure: rc 3, no create" "[ $RC -eq 3 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_LOOKUP=FAILED && ! grep -q '^issue create' '$GH_LOG'"
179
+
180
+ # 15. e6: untrusted claim comment is ignored (falls through to create)
181
+ reset
182
+ issue_json 77 open OWNER; claim_json NONE 77
183
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null); RC=$?
184
+ check "untrusted-claim: ignored, create filed" "[ $RC -eq 0 ] && grep -q '^issue create' '$GH_LOG'"
185
+
186
+ # 16. e7: several claims, lowest valid wins; a claim to a missing issue falls through
187
+ reset
188
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 80 open OWNER; issue_json 90 open OWNER; claim_json OWNER 70 90 80
189
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
190
+ check "multi-claim: lowest valid (80) wins, missing 70 skipped" "[ $RC -eq 0 ] && printf '%s' \"\$OUT\" | grep -q 'batched_into=#80 filed=0'"
191
+
192
+ # 17. DRY_RUN: no claim, reopen or close
193
+ reset; export DRY_RUN=1
194
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 closed OWNER; claim_json OWNER 77
195
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
196
+ check "dry-run: no comment/reopen/close/edit" "! grep -qE '^issue (comment|reopen|close|edit|create)' '$GH_LOG'"
197
+
105
198
  echo "passed=$PASS failed=$FAIL"
106
199
  [ "$FAIL" -eq 0 ]
@@ -51,6 +51,8 @@ _forge_tel_record() { # event k=v... -> one JSON line on stdout
51
51
  run: ($run | str), phase: ($phase | str), event: $ev, eid: $eid} + $p' --args "${args[@]}"
52
52
  }
53
53
 
54
+ # build_selfreview (forge#3971: tier, agents, findings, fixed, declined, skipped=<reason>; ids and counts only) has no review-side
55
+ # routing: it defaults to the issue target below, like diff_size.
54
56
  # finding_disposition payload is generic key=value (incl. scope=introduced|pre-existing|unknown); no per-field allowlist applies.
55
57
  # Prints "<number> <repo>" of the comment target (issue or PR) for this event, or nothing when none is usable.
56
58
  _forge_tel_target() {