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.
@@ -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
@@ -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") || {
@@ -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. For each candidate open issue, count shared tokens (length ≥ 4) between
31
- # the proposed title and the candidate title.
32
- # 4. MATCH threshold: ≥ 3 shared tokens OR shared token count ≥ 50% of
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
- # 5. On match: print "DUPLICATE: #<N> <title>" to stdout, exit 1.
35
- # 6. On no match: exit 0 (silent).
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 ]]