forgedock 1.3.0 → 1.5.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/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# extract-affected-files.test.sh — Unit-style tests for scripts/extract-affected-files.sh
|
|
3
|
+
#
|
|
4
|
+
# No network/GitHub API access required: `gh` is replaced with a mock that
|
|
5
|
+
# returns fixture text via MOCK_GH_COMMENTS (FORGE:INVESTIGATOR comment body,
|
|
6
|
+
# already pre-filtered the way `gh api ... --jq 'select(...)|.body'` would
|
|
7
|
+
# return it) and MOCK_GH_BODY (raw issue body, as `gh issue view --json body
|
|
8
|
+
# --jq '.body'` would return it) — same fixture-file-via-env-var mocking
|
|
9
|
+
# convention as scripts/issue-dedup.test.sh.
|
|
10
|
+
#
|
|
11
|
+
# Usage: bash scripts/extract-affected-files.test.sh
|
|
12
|
+
# Exit code: 0 if all assertions pass, 1 if any fail.
|
|
13
|
+
#
|
|
14
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
15
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
16
|
+
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
EXTRACT="$SCRIPT_DIR/extract-affected-files.sh"
|
|
21
|
+
|
|
22
|
+
TMP_BIN=$(mktemp -d)
|
|
23
|
+
TMP_FIXTURES=$(mktemp -d)
|
|
24
|
+
cleanup() {
|
|
25
|
+
rm -rf "$TMP_BIN" "$TMP_FIXTURES"
|
|
26
|
+
}
|
|
27
|
+
trap cleanup EXIT
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Mock `gh` — intercepts:
|
|
31
|
+
# gh api repos/<repo>/issues/<num>/comments --jq '...' -> cat $MOCK_GH_COMMENTS (if set+exists, else empty)
|
|
32
|
+
# gh issue view <num> -R <repo> --json body --jq '...' -> cat $MOCK_GH_BODY (if set+exists, else empty)
|
|
33
|
+
# Any other invocation is a test setup error.
|
|
34
|
+
#
|
|
35
|
+
# forge#2504: MOCK_GH_COMMENTS_FAIL=1 / MOCK_GH_BODY_FAIL=1 force the respective
|
|
36
|
+
# call site to simulate a genuine `gh` failure (non-zero exit, nothing on
|
|
37
|
+
# stdout) instead of a "call succeeded, empty/no-match result" — this is the
|
|
38
|
+
# distinction extract-affected-files.sh must now surface as PROVENANCE=error.
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
cat > "$TMP_BIN/gh" <<'MOCK'
|
|
41
|
+
#!/usr/bin/env bash
|
|
42
|
+
if [[ "$1" == "api" ]]; then
|
|
43
|
+
if [[ "${MOCK_GH_COMMENTS_FAIL:-}" == "1" ]]; then
|
|
44
|
+
echo "mock gh api: simulated failure (network error / rate limit)" >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
if [[ -n "${MOCK_GH_COMMENTS:-}" && -f "${MOCK_GH_COMMENTS:-}" ]]; then
|
|
48
|
+
cat "$MOCK_GH_COMMENTS"
|
|
49
|
+
fi
|
|
50
|
+
exit 0
|
|
51
|
+
fi
|
|
52
|
+
if [[ "$1" == "issue" && "$2" == "view" ]]; then
|
|
53
|
+
if [[ "${MOCK_GH_BODY_FAIL:-}" == "1" ]]; then
|
|
54
|
+
echo "mock gh issue view: simulated failure (network error / rate limit)" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
if [[ -n "${MOCK_GH_BODY:-}" && -f "${MOCK_GH_BODY:-}" ]]; then
|
|
58
|
+
cat "$MOCK_GH_BODY"
|
|
59
|
+
fi
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
echo "extract-affected-files.test.sh: unexpected gh mock invocation: $*" >&2
|
|
63
|
+
exit 1
|
|
64
|
+
MOCK
|
|
65
|
+
chmod +x "$TMP_BIN/gh"
|
|
66
|
+
|
|
67
|
+
export PATH="$TMP_BIN:$PATH"
|
|
68
|
+
|
|
69
|
+
PASS=0
|
|
70
|
+
FAIL=0
|
|
71
|
+
|
|
72
|
+
# assert_output <description> <expected_provenance> <expected_files_csv_or_empty> <extract args...>
|
|
73
|
+
#
|
|
74
|
+
# forge#2504: stdout is captured separately from stderr (`2>/dev/null`, not
|
|
75
|
+
# `2>&1`) because the script's PROVENANCE=/file-list contract lives entirely
|
|
76
|
+
# on stdout — the new gh-failure paths intentionally also emit a stderr
|
|
77
|
+
# WARNING (see extract-affected-files.sh), which must not be merged into the
|
|
78
|
+
# line this helper parses as "line 1 == PROVENANCE=...".
|
|
79
|
+
assert_output() {
|
|
80
|
+
local desc="$1" expected_prov="$2" expected_files="$3"
|
|
81
|
+
shift 3
|
|
82
|
+
OUT=$("$EXTRACT" "$@" 2>/dev/null)
|
|
83
|
+
ACTUAL_PROV=$(echo "$OUT" | head -1)
|
|
84
|
+
ACTUAL_FILES=$(echo "$OUT" | tail -n +2 | tr '\n' ',' | sed 's/,$//')
|
|
85
|
+
if [[ "$ACTUAL_PROV" == "PROVENANCE=$expected_prov" && "$ACTUAL_FILES" == "$expected_files" ]]; then
|
|
86
|
+
echo "PASS: $desc"
|
|
87
|
+
PASS=$((PASS + 1))
|
|
88
|
+
else
|
|
89
|
+
echo "FAIL: $desc"
|
|
90
|
+
echo " expected: PROVENANCE=$expected_prov / files=[$expected_files]"
|
|
91
|
+
echo " actual: $ACTUAL_PROV / files=[$ACTUAL_FILES]"
|
|
92
|
+
FAIL=$((FAIL + 1))
|
|
93
|
+
fi
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# assert_exit <description> <expected_exit> <extract args...>
|
|
97
|
+
assert_exit() {
|
|
98
|
+
local desc="$1" expected="$2"
|
|
99
|
+
shift 2
|
|
100
|
+
set +e
|
|
101
|
+
OUT=$("$EXTRACT" "$@" 2>&1)
|
|
102
|
+
ACTUAL=$?
|
|
103
|
+
set -e
|
|
104
|
+
if [[ "$ACTUAL" -eq "$expected" ]]; then
|
|
105
|
+
echo "PASS: $desc"
|
|
106
|
+
PASS=$((PASS + 1))
|
|
107
|
+
else
|
|
108
|
+
echo "FAIL: $desc (expected exit $expected, got $ACTUAL)"
|
|
109
|
+
echo " output: $OUT"
|
|
110
|
+
FAIL=$((FAIL + 1))
|
|
111
|
+
fi
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
# Scenario 1 (forge#2382 regression fixture): no FORGE:INVESTIGATOR comment.
|
|
116
|
+
# Issue body lists three context-only paths under "## Context" and has NO
|
|
117
|
+
# "## Affected Files" section. Must yield PROVENANCE=none and zero files —
|
|
118
|
+
# NOT the pre-fix behavior of scraping the three context paths.
|
|
119
|
+
# --------------------------------------------------------------------------- #
|
|
120
|
+
BODY_2382="$TMP_FIXTURES/body_2382.txt"
|
|
121
|
+
cat > "$BODY_2382" <<'EOF'
|
|
122
|
+
## Problem
|
|
123
|
+
|
|
124
|
+
Something is broken in the batching logic.
|
|
125
|
+
|
|
126
|
+
## Context
|
|
127
|
+
|
|
128
|
+
This follows the pattern of `bin/hooks/pre-tool-use.mjs`, already implements
|
|
129
|
+
part of `scripts/transition-label.sh`, and is related to prior work in
|
|
130
|
+
`scripts/worktree-lifecycle.sh`.
|
|
131
|
+
|
|
132
|
+
## Root Cause
|
|
133
|
+
|
|
134
|
+
Unknown — investigation needed.
|
|
135
|
+
EOF
|
|
136
|
+
|
|
137
|
+
unset MOCK_GH_COMMENTS
|
|
138
|
+
MOCK_GH_BODY="$BODY_2382" assert_output \
|
|
139
|
+
"forge#2382 regression: context-only paths under ## Context, no ## Affected Files -> zero files, PROVENANCE=none" \
|
|
140
|
+
"none" "" 2382 -R test/repo
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------- #
|
|
143
|
+
# Scenario 2: body has a real "## Affected Files" section AND an unrelated
|
|
144
|
+
# "## Context" section with different paths. Only the Affected Files paths
|
|
145
|
+
# must be extracted; the Context paths must be ignored.
|
|
146
|
+
# --------------------------------------------------------------------------- #
|
|
147
|
+
BODY_SCOPED="$TMP_FIXTURES/body_scoped.txt"
|
|
148
|
+
cat > "$BODY_SCOPED" <<'EOF'
|
|
149
|
+
## Problem
|
|
150
|
+
|
|
151
|
+
Bug description.
|
|
152
|
+
|
|
153
|
+
## Affected Files
|
|
154
|
+
|
|
155
|
+
1. `bin/engine.mjs` — fix the thing
|
|
156
|
+
2. `bin/engine/phases.mjs` — related fix
|
|
157
|
+
|
|
158
|
+
## Context
|
|
159
|
+
|
|
160
|
+
Follows the pattern of `bin/unrelated-context-file.mjs`.
|
|
161
|
+
|
|
162
|
+
## Expected Behavior
|
|
163
|
+
|
|
164
|
+
It should work.
|
|
165
|
+
EOF
|
|
166
|
+
|
|
167
|
+
unset MOCK_GH_COMMENTS
|
|
168
|
+
MOCK_GH_BODY="$BODY_SCOPED" assert_output \
|
|
169
|
+
"body-fallback scoped to ## Affected Files: extracts only the 2 listed files, ignores ## Context path" \
|
|
170
|
+
"body-fallback" "bin/engine.mjs,bin/engine/phases.mjs" 2383 -R test/repo
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
# Scenario 3: extension regex must cover mjs/js/sh/md (previously missing).
|
|
174
|
+
# ## Deliverables heading variant also exercised here.
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
BODY_EXT="$TMP_FIXTURES/body_ext.txt"
|
|
177
|
+
cat > "$BODY_EXT" <<'EOF'
|
|
178
|
+
## Deliverables
|
|
179
|
+
|
|
180
|
+
1. `bin/cli-spawn-shared.mjs`
|
|
181
|
+
2. `scripts/danger-zones.mjs`
|
|
182
|
+
3. `scripts/flaky-quarantine.sh`
|
|
183
|
+
4. `devdocs/project/architecture.md`
|
|
184
|
+
EOF
|
|
185
|
+
|
|
186
|
+
unset MOCK_GH_COMMENTS
|
|
187
|
+
MOCK_GH_BODY="$BODY_EXT" assert_output \
|
|
188
|
+
"extension regex covers mjs/sh/md under ## Deliverables heading" \
|
|
189
|
+
"body-fallback" "bin/cli-spawn-shared.mjs,devdocs/project/architecture.md,scripts/danger-zones.mjs,scripts/flaky-quarantine.sh" \
|
|
190
|
+
2384 -R test/repo
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------- #
|
|
193
|
+
# Scenario 4: "### Files to change" heading variant.
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
BODY_FTC="$TMP_FIXTURES/body_ftc.txt"
|
|
196
|
+
cat > "$BODY_FTC" <<'EOF'
|
|
197
|
+
## Problem
|
|
198
|
+
|
|
199
|
+
Doc-only change.
|
|
200
|
+
|
|
201
|
+
### Files to change
|
|
202
|
+
|
|
203
|
+
- `docs/site/troubleshooting.md`
|
|
204
|
+
|
|
205
|
+
## Context
|
|
206
|
+
|
|
207
|
+
References `bin/registry.mjs` for background.
|
|
208
|
+
EOF
|
|
209
|
+
|
|
210
|
+
unset MOCK_GH_COMMENTS
|
|
211
|
+
MOCK_GH_BODY="$BODY_FTC" assert_output \
|
|
212
|
+
"### Files to change heading scoped correctly, ## Context path ignored" \
|
|
213
|
+
"body-fallback" "docs/site/troubleshooting.md" 2385 -R test/repo
|
|
214
|
+
|
|
215
|
+
# --------------------------------------------------------------------------- #
|
|
216
|
+
# Scenario 5: FORGE:INVESTIGATOR comment present -> primary path used,
|
|
217
|
+
# scoped to its own "### Affected Files" section. Paths in "### Evidence"
|
|
218
|
+
# and "### Root Cause" sections of the SAME comment must be ignored. The
|
|
219
|
+
# raw issue body (which may itself contain misleading context paths) must
|
|
220
|
+
# never be consulted when an INVESTIGATOR comment exists.
|
|
221
|
+
# --------------------------------------------------------------------------- #
|
|
222
|
+
INVESTIGATOR_COMMENT="$TMP_FIXTURES/investigator_comment.txt"
|
|
223
|
+
cat > "$INVESTIGATOR_COMMENT" <<'EOF'
|
|
224
|
+
<!-- FORGE:INVESTIGATOR -->
|
|
225
|
+
## Investigation Report
|
|
226
|
+
|
|
227
|
+
**Verdict**: CONFIRMED
|
|
228
|
+
|
|
229
|
+
### Root Cause
|
|
230
|
+
Traced through `bin/engine/reconcile.mjs` before landing on the real cause.
|
|
231
|
+
|
|
232
|
+
### Affected Files
|
|
233
|
+
1. `bin/engine/state.mjs`
|
|
234
|
+
2. `bin/engine/projector.mjs`
|
|
235
|
+
|
|
236
|
+
### Evidence
|
|
237
|
+
See `bin/engine/invariants.mjs` for the assertion that fails.
|
|
238
|
+
|
|
239
|
+
<!-- INVESTIGATION:COMPLETE -->
|
|
240
|
+
EOF
|
|
241
|
+
|
|
242
|
+
BODY_SHOULD_BE_IGNORED="$TMP_FIXTURES/body_should_be_ignored.txt"
|
|
243
|
+
cat > "$BODY_SHOULD_BE_IGNORED" <<'EOF'
|
|
244
|
+
## Context
|
|
245
|
+
|
|
246
|
+
Totally different file: `bin/unrelated.mjs`
|
|
247
|
+
EOF
|
|
248
|
+
|
|
249
|
+
MOCK_GH_COMMENTS="$INVESTIGATOR_COMMENT" MOCK_GH_BODY="$BODY_SHOULD_BE_IGNORED" assert_output \
|
|
250
|
+
"INVESTIGATOR comment present: primary path scoped to its own ### Affected Files, Root Cause/Evidence ignored, raw body never consulted" \
|
|
251
|
+
"affected-files-section" "bin/engine/projector.mjs,bin/engine/state.mjs" 2386 -R test/repo
|
|
252
|
+
|
|
253
|
+
# --------------------------------------------------------------------------- #
|
|
254
|
+
# Scenario 6: no INVESTIGATOR comment AND no deliverables-shaped heading in
|
|
255
|
+
# the body at all (not even "## Context") -> PROVENANCE=none.
|
|
256
|
+
# --------------------------------------------------------------------------- #
|
|
257
|
+
BODY_NO_HEADINGS="$TMP_FIXTURES/body_no_headings.txt"
|
|
258
|
+
cat > "$BODY_NO_HEADINGS" <<'EOF'
|
|
259
|
+
Just some prose mentioning `bin/foo.mjs` with no headings at all.
|
|
260
|
+
EOF
|
|
261
|
+
|
|
262
|
+
unset MOCK_GH_COMMENTS
|
|
263
|
+
MOCK_GH_BODY="$BODY_NO_HEADINGS" assert_output \
|
|
264
|
+
"no INVESTIGATOR comment, no deliverables heading at all -> PROVENANCE=none" \
|
|
265
|
+
"none" "" 2387 -R test/repo
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------------------------- #
|
|
268
|
+
# Scenario 7 (forge#2503 regression fixture): positive-path pre-joined
|
|
269
|
+
# single-token -R form, e.g. "-R owner/repo" as ONE argv element instead of
|
|
270
|
+
# two separate tokens. The script's usage comment and its `-R\ *` case arm
|
|
271
|
+
# both document this form as supported, but until this fixture the only
|
|
272
|
+
# assertion touching that arm was the malformed-value negative case in
|
|
273
|
+
# Scenario 8 below — the success branch (REPO="$_rval"; shift) had zero
|
|
274
|
+
# positive-path coverage. Reuses the BODY_SCOPED fixture from Scenario 2;
|
|
275
|
+
# only the argv form differs.
|
|
276
|
+
# --------------------------------------------------------------------------- #
|
|
277
|
+
unset MOCK_GH_COMMENTS
|
|
278
|
+
MOCK_GH_BODY="$BODY_SCOPED" assert_output \
|
|
279
|
+
"pre-joined single-token -R form (\"-R test/repo\" as one argv element): body-fallback scoped to ## Affected Files" \
|
|
280
|
+
"body-fallback" "bin/engine.mjs,bin/engine/phases.mjs" 2390 "-R test/repo"
|
|
281
|
+
|
|
282
|
+
# --------------------------------------------------------------------------- #
|
|
283
|
+
# Scenario 8: usage errors.
|
|
284
|
+
# --------------------------------------------------------------------------- #
|
|
285
|
+
assert_exit "missing issue number -> exit 2" 2 -R test/repo
|
|
286
|
+
assert_exit "missing value for -R -> exit 2" 2 2388 -R
|
|
287
|
+
assert_exit "malformed -R value (no slash, pre-joined token) -> exit 2" 2 2389 "-R notaslash"
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------- #
|
|
290
|
+
# Scenario 9 (forge#2504 regression fixture): primary `gh api` call fails
|
|
291
|
+
# outright (simulated network error / rate limit) and there is no usable
|
|
292
|
+
# fallback body either. Must yield PROVENANCE=error, NOT the pre-fix
|
|
293
|
+
# behavior of silently falling through to PROVENANCE=none as if this were
|
|
294
|
+
# a confirmed-empty result.
|
|
295
|
+
# --------------------------------------------------------------------------- #
|
|
296
|
+
unset MOCK_GH_COMMENTS MOCK_GH_BODY MOCK_GH_BODY_FAIL
|
|
297
|
+
MOCK_GH_COMMENTS_FAIL=1 assert_output \
|
|
298
|
+
"forge#2504: primary gh api call fails, no usable fallback -> PROVENANCE=error, not none" \
|
|
299
|
+
"error" "" 2504 -R test/repo
|
|
300
|
+
|
|
301
|
+
# --------------------------------------------------------------------------- #
|
|
302
|
+
# Scenario 10 (forge#2504 regression fixture): no FORGE:INVESTIGATOR comment
|
|
303
|
+
# (primary path legitimately empty, not failed) but the fallback `gh issue
|
|
304
|
+
# view` call fails outright. Must yield PROVENANCE=error, NOT none.
|
|
305
|
+
# --------------------------------------------------------------------------- #
|
|
306
|
+
unset MOCK_GH_COMMENTS MOCK_GH_COMMENTS_FAIL
|
|
307
|
+
MOCK_GH_BODY_FAIL=1 assert_output \
|
|
308
|
+
"forge#2504: no INVESTIGATOR comment (legitimately empty) + fallback gh issue view fails -> PROVENANCE=error, not none" \
|
|
309
|
+
"error" "" 2505 -R test/repo
|
|
310
|
+
|
|
311
|
+
# --------------------------------------------------------------------------- #
|
|
312
|
+
# Scenario 11 (forge#2504 regression fixture): primary `gh api` call fails,
|
|
313
|
+
# but the fallback `gh issue view` call succeeds and yields real files. A
|
|
314
|
+
# gh failure must never suppress or overwrite data actually recovered by a
|
|
315
|
+
# subsequent successful call — this must still report body-fallback, not
|
|
316
|
+
# error. Reuses the BODY_SCOPED fixture from Scenario 2.
|
|
317
|
+
# --------------------------------------------------------------------------- #
|
|
318
|
+
unset MOCK_GH_BODY_FAIL
|
|
319
|
+
MOCK_GH_COMMENTS_FAIL=1 MOCK_GH_BODY="$BODY_SCOPED" assert_output \
|
|
320
|
+
"forge#2504: primary gh api call fails but fallback succeeds with real files -> body-fallback, not error" \
|
|
321
|
+
"body-fallback" "bin/engine.mjs,bin/engine/phases.mjs" 2506 -R test/repo
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- #
|
|
324
|
+
# Summary
|
|
325
|
+
# --------------------------------------------------------------------------- #
|
|
326
|
+
echo ""
|
|
327
|
+
echo "Results: $PASS passed, $FAIL failed"
|
|
328
|
+
if [[ "$FAIL" -gt 0 ]]; then
|
|
329
|
+
exit 1
|
|
330
|
+
fi
|
|
331
|
+
exit 0
|
package/scripts/forge-run.sh
CHANGED
|
@@ -72,17 +72,48 @@ die() {
|
|
|
72
72
|
local message="$2"
|
|
73
73
|
local ts_val
|
|
74
74
|
ts_val=$(ts)
|
|
75
|
-
#
|
|
76
|
-
|
|
75
|
+
# Route through json_str for full JSON-string escaping (backslash, quote, and
|
|
76
|
+
# control characters) — die() is fed raw multi-line `gh` CLI stderr, which may
|
|
77
|
+
# contain backslashes and newlines that a quote-only escape would not handle.
|
|
78
|
+
message=$(json_str "$message")
|
|
77
79
|
emit "{\"event\":\"error\",\"code\":\"${code}\",\"message\":\"${message}\",\"ts\":\"${ts_val}\"}"
|
|
78
80
|
exit 1
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
# JSON-escape a string
|
|
83
|
+
# JSON-escape a string: backslash, double-quote, and the full C0 control range
|
|
84
|
+
# (U+0000-U+001F), per RFC 8259 section 7. Order matters:
|
|
85
|
+
# 1. Backslash escaping runs first so a literal backslash becomes \\ before
|
|
86
|
+
# any other pass runs, avoiding double-escaping ambiguity.
|
|
87
|
+
# 2. Double-quote next.
|
|
88
|
+
# 3. The three named control escapes (\n, \r, \t) next, so they get their
|
|
89
|
+
# short two-character form rather than falling through to \u00XX.
|
|
90
|
+
# 4. Every remaining C0 control byte (U+0000-U+001F, excluding \n/\r/\t
|
|
91
|
+
# already handled above) is escaped as \u00XX — bash's ${s//pat/rep}
|
|
92
|
+
# cannot loop over an arbitrary byte range in one substitution, so this
|
|
93
|
+
# is a bounded loop over hex 00-1F.
|
|
82
94
|
json_str() {
|
|
83
95
|
local s="$1"
|
|
84
96
|
s="${s//\\/\\\\}"
|
|
85
97
|
s="${s//\"/\\\"}"
|
|
98
|
+
s="${s//$'\n'/\\n}"
|
|
99
|
+
s="${s//$'\r'/\\r}"
|
|
100
|
+
s="${s//$'\t'/\\t}"
|
|
101
|
+
local i
|
|
102
|
+
for ((i = 0; i <= 31; i++)); do
|
|
103
|
+
# \n (10=0x0a), \r (13=0x0d), \t (9=0x09) already escaped above — skip them
|
|
104
|
+
# here so they keep their short named form instead of becoming \u00XX.
|
|
105
|
+
if [ "$i" -eq 9 ] || [ "$i" -eq 10 ] || [ "$i" -eq 13 ]; then
|
|
106
|
+
continue
|
|
107
|
+
fi
|
|
108
|
+
local hex
|
|
109
|
+
hex=$(printf '%02x' "$i")
|
|
110
|
+
local ctrl_char
|
|
111
|
+
printf -v ctrl_char '\\x%02x' "$i"
|
|
112
|
+
ctrl_char=$(printf "$ctrl_char")
|
|
113
|
+
if [[ "$s" == *"$ctrl_char"* ]]; then
|
|
114
|
+
s="${s//$ctrl_char/\\u00$hex}"
|
|
115
|
+
fi
|
|
116
|
+
done
|
|
86
117
|
printf '%s' "$s"
|
|
87
118
|
}
|
|
88
119
|
|
|
@@ -151,6 +182,8 @@ fi
|
|
|
151
182
|
|
|
152
183
|
# Fetch issue: state, labels
|
|
153
184
|
GH_STDERR_TMP=$(mktemp)
|
|
185
|
+
chmod 600 "$GH_STDERR_TMP"
|
|
186
|
+
trap 'rm -f "$GH_STDERR_TMP"' EXIT
|
|
154
187
|
ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
155
188
|
--json number,state,labels,title \
|
|
156
189
|
2>"$GH_STDERR_TMP") || {
|
|
@@ -198,6 +231,8 @@ fi
|
|
|
198
231
|
# via GH_REPO_ARGS and handles both default-repo and explicit-repo cases.
|
|
199
232
|
# gh api repos/{owner}/{repo}/... would need GH_REPO env var for -R override — avoided here.
|
|
200
233
|
GH_STDERR_TMP=$(mktemp)
|
|
234
|
+
chmod 600 "$GH_STDERR_TMP"
|
|
235
|
+
trap 'rm -f "$GH_STDERR_TMP"' EXIT
|
|
201
236
|
COMMENTS_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
202
237
|
--json comments --jq '[.comments[] | {id: .id, body: .body}]' \
|
|
203
238
|
2>"$GH_STDERR_TMP") || {
|
|
@@ -315,7 +350,7 @@ emit "{\"event\":\"phase_detail\",\"phase\":\"$(json_str "${PHASE}")\",\"lane\":
|
|
|
315
350
|
# Event 3: action_required (only for non-terminal active phases)
|
|
316
351
|
if [ -n "$ACTION" ]; then
|
|
317
352
|
ts_val=$(ts)
|
|
318
|
-
ACTION_ESCAPED
|
|
353
|
+
ACTION_ESCAPED=$(json_str "${ACTION}")
|
|
319
354
|
emit "{\"event\":\"action_required\",\"phase\":\"$(json_str "${PHASE}")\",\"action\":\"${ACTION_ESCAPED}\",\"ts\":\"${ts_val}\"}"
|
|
320
355
|
fi
|
|
321
356
|
|
package/scripts/issue-dedup.sh
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# issue-dedup.sh — Deterministic near-duplicate check for GitHub issue creation
|
|
3
3
|
#
|
|
4
4
|
# Usage:
|
|
5
|
-
# issue-dedup.sh <title> [-R <owner/repo>] [--force]
|
|
6
|
-
# issue-dedup.sh <title> ["-R <owner/repo>"] [--force] (single pre-joined token also accepted)
|
|
5
|
+
# issue-dedup.sh <title> [-R <owner/repo>] [--force] [--exclude <N,N,...>]
|
|
6
|
+
# issue-dedup.sh <title> ["-R <owner/repo>"] [--force] [--exclude <N,N,...>] (single pre-joined -R token also accepted)
|
|
7
7
|
#
|
|
8
8
|
# <title> : Proposed issue title (required, must be quoted if it contains spaces)
|
|
9
9
|
# -R <owner/repo> : GitHub repository, as two argv tokens (optional, defaults to
|
|
@@ -15,6 +15,19 @@
|
|
|
15
15
|
# correctly detected by the parser and treated as a title, not a
|
|
16
16
|
# repo flag. See the -R\ * case arm guard in the arg-parsing loop.
|
|
17
17
|
# --force : Skip dedup check and always allow creation (explicit override)
|
|
18
|
+
# --exclude <N,N,...> : Comma-separated issue numbers to remove from the candidate
|
|
19
|
+
# set BEFORE token matching runs. Accepts either "--exclude N,N"
|
|
20
|
+
# (two argv tokens) or "--exclude=N,N" (one pre-joined token).
|
|
21
|
+
# Intended for callers that are about to create an issue that is a
|
|
22
|
+
# deliberate supersede of other, already-open issues (e.g. a P3
|
|
23
|
+
# review-finding batch issue restating its own member findings) —
|
|
24
|
+
# those members would otherwise always collide with the new title
|
|
25
|
+
# by construction. This is NOT a --force equivalent: it narrows the
|
|
26
|
+
# candidate set, it does not disable the check. Any OTHER open issue
|
|
27
|
+
# (not in the exclusion list) is still matched normally, so a batch
|
|
28
|
+
# title that happens to duplicate an unrelated existing issue is
|
|
29
|
+
# still caught. See commands/issue.md Phase 2D for the integration
|
|
30
|
+
# contract. <!-- Added: forge#2432 -->
|
|
18
31
|
#
|
|
19
32
|
# Exit codes:
|
|
20
33
|
# 0 — No near-duplicate found. Creation is safe. Stdout is empty.
|
|
@@ -27,12 +40,13 @@
|
|
|
27
40
|
# of length ≥ 4 (short words are noise: "fix", "add", "the", etc.)
|
|
28
41
|
# 2. Query open issues: search GitHub using the 2 most distinctive title tokens
|
|
29
42
|
# as the --search query (balances recall vs API cost)
|
|
30
|
-
# 3.
|
|
31
|
-
#
|
|
32
|
-
#
|
|
43
|
+
# 3. Remove any --exclude'd issue numbers from the candidate set.
|
|
44
|
+
# 4. For each remaining candidate open issue, count shared tokens (length ≥ 4)
|
|
45
|
+
# between the proposed title and the candidate title.
|
|
46
|
+
# 5. MATCH threshold: ≥ 3 shared tokens OR shared token count ≥ 50% of
|
|
33
47
|
# the shorter title's token set — whichever fires first.
|
|
34
|
-
#
|
|
35
|
-
#
|
|
48
|
+
# 6. On match: print "DUPLICATE: #<N> <title>" to stdout, exit 1.
|
|
49
|
+
# 7. On no match: exit 0 (silent).
|
|
36
50
|
#
|
|
37
51
|
# Integration pattern (for command specs that call gh issue create):
|
|
38
52
|
#
|
|
@@ -66,6 +80,10 @@
|
|
|
66
80
|
# "Regression of #N" reference; see issue.md Phase 2D).
|
|
67
81
|
# - The --force flag is the explicit override path. Human decision is required
|
|
68
82
|
# to use it — agents MUST NOT pass --force without user authorization.
|
|
83
|
+
# - The --exclude flag is NOT a --force equivalent and requires no human
|
|
84
|
+
# authorization: it narrows the candidate set to exclude specific already-
|
|
85
|
+
# known issue numbers (e.g. a batch's own declared members), it does not
|
|
86
|
+
# disable the check. Any other open issue is still matched normally.
|
|
69
87
|
# - Token matching is case-insensitive and punctuation-agnostic.
|
|
70
88
|
# - GitHub's --search flag performs a full-text search; the token query is a
|
|
71
89
|
# tiebreaker against the full candidate list, not the sole filter.
|
|
@@ -81,6 +99,7 @@ set -euo pipefail
|
|
|
81
99
|
PROPOSED_TITLE=""
|
|
82
100
|
GH_FLAG=""
|
|
83
101
|
FORCE=0
|
|
102
|
+
EXCLUDE_ARG=""
|
|
84
103
|
|
|
85
104
|
while [[ $# -gt 0 ]]; do
|
|
86
105
|
case "$1" in
|
|
@@ -121,6 +140,19 @@ while [[ $# -gt 0 ]]; do
|
|
|
121
140
|
FORCE=1
|
|
122
141
|
shift
|
|
123
142
|
;;
|
|
143
|
+
--exclude)
|
|
144
|
+
shift
|
|
145
|
+
if [[ $# -eq 0 ]]; then
|
|
146
|
+
echo "Missing value for --exclude" >&2
|
|
147
|
+
exit 2
|
|
148
|
+
fi
|
|
149
|
+
EXCLUDE_ARG="$1"
|
|
150
|
+
shift
|
|
151
|
+
;;
|
|
152
|
+
--exclude=*)
|
|
153
|
+
EXCLUDE_ARG="${1#--exclude=}"
|
|
154
|
+
shift
|
|
155
|
+
;;
|
|
124
156
|
-*)
|
|
125
157
|
echo "Unknown flag: $1" >&2
|
|
126
158
|
exit 2
|
|
@@ -133,7 +165,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
133
165
|
done
|
|
134
166
|
|
|
135
167
|
if [[ -z "$PROPOSED_TITLE" ]]; then
|
|
136
|
-
echo "Usage: issue-dedup.sh <title> [-R <owner/repo>] [--force]" >&2
|
|
168
|
+
echo "Usage: issue-dedup.sh <title> [-R <owner/repo>] [--force] [--exclude <N,N,...>]" >&2
|
|
137
169
|
exit 2
|
|
138
170
|
fi
|
|
139
171
|
|
|
@@ -142,6 +174,27 @@ if [[ "$FORCE" -eq 1 ]]; then
|
|
|
142
174
|
exit 0
|
|
143
175
|
fi
|
|
144
176
|
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# Exclusion set (--exclude N,N,N)
|
|
179
|
+
# Narrows the candidate set BEFORE token matching — does NOT disable the gate.
|
|
180
|
+
# Non-numeric/malformed tokens are silently dropped rather than treated as a
|
|
181
|
+
# usage error, since callers typically build this list programmatically from
|
|
182
|
+
# a bash array of issue numbers (e.g. a batch's own member issues) and should
|
|
183
|
+
# not have to pre-validate it. Same "|| true" pattern as the grep -c hazard
|
|
184
|
+
# documented below — a filter that legitimately matches nothing must not trip
|
|
185
|
+
# `set -e`.
|
|
186
|
+
# --------------------------------------------------------------------------- #
|
|
187
|
+
EXCLUDE_SET=""
|
|
188
|
+
if [[ -n "$EXCLUDE_ARG" ]]; then
|
|
189
|
+
EXCLUDE_SET=$(echo "$EXCLUDE_ARG" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -E '^[0-9]+$' || true)
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
is_excluded() {
|
|
193
|
+
local num="$1"
|
|
194
|
+
[[ -z "$EXCLUDE_SET" ]] && return 1
|
|
195
|
+
grep -qxF "$num" <<< "$EXCLUDE_SET"
|
|
196
|
+
}
|
|
197
|
+
|
|
145
198
|
# --------------------------------------------------------------------------- #
|
|
146
199
|
# Token normalization
|
|
147
200
|
# Lowercase, strip non-alphanumeric chars except spaces, split on whitespace,
|
|
@@ -198,6 +251,14 @@ MATCH_TITLE=""
|
|
|
198
251
|
while IFS=$'\t' read -r CAND_NUMBER CAND_TITLE; do
|
|
199
252
|
[[ -z "$CAND_NUMBER" ]] && continue
|
|
200
253
|
|
|
254
|
+
# Excluded candidates (e.g. a batch issue's own declared members) are removed
|
|
255
|
+
# from the candidate set entirely — they never reach token matching, so they
|
|
256
|
+
# can never produce a false "DUPLICATE" against the very issue meant to
|
|
257
|
+
# supersede them. Any other open issue is still matched normally below.
|
|
258
|
+
if is_excluded "$CAND_NUMBER"; then
|
|
259
|
+
continue
|
|
260
|
+
fi
|
|
261
|
+
|
|
201
262
|
CAND_TOKENS=$(normalize_tokens "$CAND_TITLE")
|
|
202
263
|
# Same double-fallback hazard as PROPOSED_TOKEN_COUNT above — see comment there.
|
|
203
264
|
CAND_TOKEN_COUNT=$(echo "$CAND_TOKENS" | grep -c . || true)
|