forgedock 1.4.0 → 1.5.1
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/runlog.mjs +11 -1
- package/bin/engine-cli.mjs +6 -0
- package/bin/engine.mjs +194 -59
- package/bin/forgedock.mjs +112 -5
- package/bin/hooks/pre-tool-use.mjs +29 -7
- package/bin/journey.mjs +790 -193
- package/bin/runner.mjs +159 -2
- package/bin/watch-utils.mjs +4 -1
- package/commands/cleanup.md +27 -2
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +3 -1
- package/commands/orchestrate/phase-1-resolve.md +125 -14
- package/commands/orchestrate/phase-3-dependency.md +254 -40
- package/commands/orchestrate/phase-4-execution.md +402 -39
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +97 -32
- package/commands/work-on/build/architect.md +28 -3
- package/commands/work-on/remediate.md +53 -4
- package/package.json +1 -1
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/build-knowledge-index.mjs +6 -2
- package/scripts/check-phase-registry-drift.mjs +23 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +194 -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 +4 -0
- 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
|
@@ -182,6 +182,8 @@ fi
|
|
|
182
182
|
|
|
183
183
|
# Fetch issue: state, labels
|
|
184
184
|
GH_STDERR_TMP=$(mktemp)
|
|
185
|
+
chmod 600 "$GH_STDERR_TMP"
|
|
186
|
+
trap 'rm -f "$GH_STDERR_TMP"' EXIT
|
|
185
187
|
ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
186
188
|
--json number,state,labels,title \
|
|
187
189
|
2>"$GH_STDERR_TMP") || {
|
|
@@ -229,6 +231,8 @@ fi
|
|
|
229
231
|
# via GH_REPO_ARGS and handles both default-repo and explicit-repo cases.
|
|
230
232
|
# gh api repos/{owner}/{repo}/... would need GH_REPO env var for -R override — avoided here.
|
|
231
233
|
GH_STDERR_TMP=$(mktemp)
|
|
234
|
+
chmod 600 "$GH_STDERR_TMP"
|
|
235
|
+
trap 'rm -f "$GH_STDERR_TMP"' EXIT
|
|
232
236
|
COMMENTS_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
|
|
233
237
|
--json comments --jq '[.comments[] | {id: .id, body: .body}]' \
|
|
234
238
|
2>"$GH_STDERR_TMP") || {
|
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)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# issue-dedup.test.sh — Unit-style tests for scripts/issue-dedup.sh's --exclude flag
|
|
3
|
+
#
|
|
4
|
+
# No network/GitHub API access required: `gh` is replaced with a mock that
|
|
5
|
+
# returns fixture candidate lists via the MOCK_GH_CANDIDATES env var, mimicking
|
|
6
|
+
# the "\(.number)\t\(.title)" projection issue-dedup.sh expects from
|
|
7
|
+
# `gh issue list --json number,title --jq '...'`.
|
|
8
|
+
#
|
|
9
|
+
# Usage: bash scripts/issue-dedup.test.sh
|
|
10
|
+
# Exit code: 0 if all assertions pass, 1 if any fail.
|
|
11
|
+
#
|
|
12
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
13
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
14
|
+
|
|
15
|
+
set -uo pipefail
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
DEDUP="$SCRIPT_DIR/issue-dedup.sh"
|
|
19
|
+
|
|
20
|
+
TMP_BIN=$(mktemp -d)
|
|
21
|
+
TMP_FIXTURES=$(mktemp -d)
|
|
22
|
+
cleanup() {
|
|
23
|
+
rm -rf "$TMP_BIN" "$TMP_FIXTURES"
|
|
24
|
+
}
|
|
25
|
+
trap cleanup EXIT
|
|
26
|
+
|
|
27
|
+
# --------------------------------------------------------------------------- #
|
|
28
|
+
# Mock `gh` — intercepts `gh issue list ...` and prints the fixture file named
|
|
29
|
+
# by $MOCK_GH_CANDIDATES (tab-separated "number\ttitle" lines), ignoring all
|
|
30
|
+
# other flags/subcommands issue-dedup.sh passes through. Any other `gh`
|
|
31
|
+
# invocation is treated as an unexpected test setup error.
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
cat > "$TMP_BIN/gh" <<'MOCK'
|
|
34
|
+
#!/usr/bin/env bash
|
|
35
|
+
if [[ "$1" == "issue" && "$2" == "list" ]]; then
|
|
36
|
+
if [[ -n "${MOCK_GH_CANDIDATES:-}" && -f "${MOCK_GH_CANDIDATES:-}" ]]; then
|
|
37
|
+
cat "$MOCK_GH_CANDIDATES"
|
|
38
|
+
fi
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
echo "issue-dedup.test.sh: unexpected gh mock invocation: $*" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
MOCK
|
|
44
|
+
chmod +x "$TMP_BIN/gh"
|
|
45
|
+
|
|
46
|
+
export PATH="$TMP_BIN:$PATH"
|
|
47
|
+
|
|
48
|
+
PASS=0
|
|
49
|
+
FAIL=0
|
|
50
|
+
|
|
51
|
+
# assert_exit <description> <expected_exit> <dedup args...>
|
|
52
|
+
assert_exit() {
|
|
53
|
+
local desc="$1" expected="$2"
|
|
54
|
+
shift 2
|
|
55
|
+
set +e
|
|
56
|
+
OUT=$("$DEDUP" "$@" 2>&1)
|
|
57
|
+
ACTUAL=$?
|
|
58
|
+
set -e
|
|
59
|
+
if [[ "$ACTUAL" -eq "$expected" ]]; then
|
|
60
|
+
echo "PASS: $desc"
|
|
61
|
+
PASS=$((PASS + 1))
|
|
62
|
+
else
|
|
63
|
+
echo "FAIL: $desc (expected exit $expected, got $ACTUAL)"
|
|
64
|
+
echo " output: $OUT"
|
|
65
|
+
FAIL=$((FAIL + 1))
|
|
66
|
+
fi
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
PROPOSED_TITLE="fix(batch): duplicate detection review findings"
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
# Scenario A: candidate set contains ONLY the batch's own declared members.
|
|
73
|
+
# Both titles are engineered to share >=3 normalized tokens (length >= 4) with
|
|
74
|
+
# the proposed title: "duplicate", "detection", "review"/"findings".
|
|
75
|
+
# --------------------------------------------------------------------------- #
|
|
76
|
+
MEMBERS_ONLY="$TMP_FIXTURES/members_only.tsv"
|
|
77
|
+
cat > "$MEMBERS_ONLY" <<'EOF'
|
|
78
|
+
2422 fix: duplicate detection review token overlap
|
|
79
|
+
2424 fix: duplicate detection findings batching logic
|
|
80
|
+
EOF
|
|
81
|
+
|
|
82
|
+
MOCK_GH_CANDIDATES="$MEMBERS_ONLY" assert_exit \
|
|
83
|
+
"baseline (no --exclude): batch title collides with its own members -> exit 1" \
|
|
84
|
+
1 "$PROPOSED_TITLE" -R test/repo
|
|
85
|
+
|
|
86
|
+
MOCK_GH_CANDIDATES="$MEMBERS_ONLY" assert_exit \
|
|
87
|
+
"--exclude '2422,2424': batch title excludes its own members -> exit 0" \
|
|
88
|
+
0 "$PROPOSED_TITLE" -R test/repo --exclude "2422,2424"
|
|
89
|
+
|
|
90
|
+
MOCK_GH_CANDIDATES="$MEMBERS_ONLY" assert_exit \
|
|
91
|
+
"--exclude=2422,2424 (pre-joined form) -> exit 0" \
|
|
92
|
+
0 "$PROPOSED_TITLE" -R test/repo --exclude=2422,2424
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
# Scenario B: candidate set has the declared members PLUS an unrelated
|
|
96
|
+
# non-member issue (#2600) that also collides on token overlap. Excluding the
|
|
97
|
+
# members must NOT suppress the non-member match — protection is preserved.
|
|
98
|
+
# --------------------------------------------------------------------------- #
|
|
99
|
+
MEMBERS_PLUS_NONMEMBER="$TMP_FIXTURES/members_plus_nonmember.tsv"
|
|
100
|
+
cat > "$MEMBERS_PLUS_NONMEMBER" <<'EOF'
|
|
101
|
+
2422 fix: duplicate detection review token overlap
|
|
102
|
+
2424 fix: duplicate detection findings batching logic
|
|
103
|
+
2600 fix: duplicate detection review triage
|
|
104
|
+
EOF
|
|
105
|
+
|
|
106
|
+
MOCK_GH_CANDIDATES="$MEMBERS_PLUS_NONMEMBER" assert_exit \
|
|
107
|
+
"--exclude '2422,2424' with a real non-member duplicate present -> still exit 1" \
|
|
108
|
+
1 "$PROPOSED_TITLE" -R test/repo --exclude "2422,2424"
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
# Scenario C: exclusion list has no bearing when there is no candidate overlap
|
|
112
|
+
# at all — sanity check that --exclude doesn't accidentally suppress dedup.
|
|
113
|
+
# --------------------------------------------------------------------------- #
|
|
114
|
+
NO_OVERLAP="$TMP_FIXTURES/no_overlap.tsv"
|
|
115
|
+
cat > "$NO_OVERLAP" <<'EOF'
|
|
116
|
+
9001 feat: totally unrelated subsystem work
|
|
117
|
+
EOF
|
|
118
|
+
|
|
119
|
+
MOCK_GH_CANDIDATES="$NO_OVERLAP" assert_exit \
|
|
120
|
+
"no overlap at all, --exclude present but irrelevant -> exit 0" \
|
|
121
|
+
0 "$PROPOSED_TITLE" -R test/repo --exclude "2422,2424"
|
|
122
|
+
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
# Scenario D: malformed / non-numeric exclusion tokens are silently dropped,
|
|
125
|
+
# not a usage error.
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
MOCK_GH_CANDIDATES="$MEMBERS_ONLY" assert_exit \
|
|
128
|
+
"malformed --exclude tokens (non-numeric) are dropped, real numeric ones still apply -> exit 0" \
|
|
129
|
+
0 "$PROPOSED_TITLE" -R test/repo --exclude "2422, 2424, abc, "
|
|
130
|
+
|
|
131
|
+
# --------------------------------------------------------------------------- #
|
|
132
|
+
# Scenario E: --exclude combined with existing -R parsing (two-token and
|
|
133
|
+
# pre-joined forms) does not regress prior -R argument-parsing fixes (#1530,
|
|
134
|
+
# #1625).
|
|
135
|
+
# --------------------------------------------------------------------------- #
|
|
136
|
+
MOCK_GH_CANDIDATES="$MEMBERS_ONLY" assert_exit \
|
|
137
|
+
"--exclude alongside pre-joined -R token -> exit 0" \
|
|
138
|
+
0 "$PROPOSED_TITLE" "-R test/repo" --exclude "2422,2424"
|
|
139
|
+
|
|
140
|
+
echo ""
|
|
141
|
+
echo "Results: $PASS passed, $FAIL failed"
|
|
142
|
+
[[ "$FAIL" -eq 0 ]]
|