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
|
@@ -8,6 +8,27 @@ install: core
|
|
|
8
8
|
|
|
9
9
|
## Phase 1: Resolve the Issue Set
|
|
10
10
|
|
|
11
|
+
### Batch-Start Timestamp (T0) — capture FIRST, before any resolution
|
|
12
|
+
|
|
13
|
+
<!-- Added: forge#2628 -->
|
|
14
|
+
|
|
15
|
+
Before parsing `$ARGUMENTS` or resolving anything, capture the batch's start timestamp once:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
BATCH_T0=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
19
|
+
echo "Batch start T0: ${BATCH_T0}"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`BATCH_T0` is the anchor for the run-spawned-vs-backlog cascade distinction used throughout this
|
|
23
|
+
file's "Cascade / Review-Finding Resolution" section below and by `phase-4-execution.md` Step 4C.
|
|
24
|
+
Persist it alongside `ORIGINATING_QUERY_KIND`/`ORIGINATING_QUERY_PATTERN`/`ORIGINATING_QUERY_ARGS`
|
|
25
|
+
(see "Predicate Persistence" below) in the batch's in-memory/report state so Phase 4 reads the
|
|
26
|
+
same value this step captured — it must never independently re-derive its own T0 from "now" at
|
|
27
|
+
whatever later moment Step 4A.pre happens to run, which would silently widen the window past what
|
|
28
|
+
this run actually spawned. If a session resumes mid-run without `BATCH_T0` in context (e.g. after
|
|
29
|
+
compaction), Phase 4's Step 4A.pre falls back to capturing its own timestamp — see that section's
|
|
30
|
+
note — but this is a degraded fallback, not the normal path.
|
|
31
|
+
|
|
11
32
|
Parse `$ARGUMENTS` to determine which issues to work on:
|
|
12
33
|
|
|
13
34
|
### Input Patterns
|
|
@@ -16,12 +37,181 @@ Parse `$ARGUMENTS` to determine which issues to work on:
|
|
|
16
37
|
|-------|------------|
|
|
17
38
|
| `milestone <slug>` | All open issues assigned to that GitHub milestone (default repo) |
|
|
18
39
|
| `#1 #2 #3` or `1 2 3` | Specific issue numbers, optionally repo-prefixed (e.g., `#123 mcp:5 n8n:12`) |
|
|
19
|
-
| `next <N>` | Top N priority open issues (
|
|
40
|
+
| `next <N>` | Top N priority open issues (P0 first, then P1, etc. — accepts both `priority:P<n>` and bare `P<n>` labels, see "Priority label schema" note under P3 batching below) |
|
|
20
41
|
| `next <N> all-repos` | Top N across ALL ecosystem repos |
|
|
21
42
|
| `fast-lane` or `fast` | All open fast-lane issues (no milestone, bugs/fixes) |
|
|
22
|
-
| `priority:P0` or `priority:P1` | All open issues with that priority label |
|
|
43
|
+
| `priority:P0` or `priority:P1` | All open issues with that priority label (matches both `priority:P<n>` and bare `P<n>` on the target repo — see "Priority label schema" note below) |
|
|
23
44
|
| `mcp:fast` or `n8n:next 3` | Repo-scoped queries |
|
|
24
|
-
|
|
|
45
|
+
| `cascade`, `review-findings`, or `findings` (optionally `--include-deferred` / `--allow-gen2` / `--include-backlog`) | `review-finding` issues created at/after this batch's T0 (default repo, or repo-scoped e.g. `mcp:cascade`) — i.e. empty unless combined with a run that has already spawned findings, or `--include-backlog` is passed. See "Cascade / Review-Finding Resolution" below for the run-spawned-vs-backlog distinction and the generation-depth admission on top of it (`orchestration.cascade.max_generation`, default 1). <!-- Added: forge#2231, forge#2234, forge#2628 -->|
|
|
46
|
+
| `<slug>` (no keyword) | Try milestone first, then fall back to label search. If both resolve to zero issues, report near-miss label candidates instead of silently resolving to nothing — see "Near-Miss Suggestion" below. <!-- Added: forge#2231 -->|
|
|
47
|
+
|
|
48
|
+
### Cascade / Review-Finding Resolution
|
|
49
|
+
|
|
50
|
+
<!-- Added: forge#2231, T0-scoping added: forge#2628 -->
|
|
51
|
+
|
|
52
|
+
When the input matches `cascade`, `review-findings`, or `findings` (case-insensitive, optionally repo-prefixed), resolve to open `review-finding`-labeled issues instead of a milestone or plain label search, then apply a generation depth check so the default set here matches what Step 4C would actually admit.
|
|
53
|
+
|
|
54
|
+
**Two modes — run-spawned cascade (default) vs. whole-backlog sweep (explicit opt-in only):**
|
|
55
|
+
|
|
56
|
+
There are two different things an operator can mean by "cascade," and this resolve step must not
|
|
57
|
+
silently conflate them:
|
|
58
|
+
|
|
59
|
+
- **(a) Run-spawned cascade (default)** — recursively work the `review-finding` issues *this batch*
|
|
60
|
+
produces. Bounded to findings created at/after `BATCH_T0` (captured at the very top of this file,
|
|
61
|
+
before any resolution runs). This is the safe default: combined with an ordinary scoped batch
|
|
62
|
+
(e.g. `<no:milestone set>` + `cascade`), it expresses "follow through on what this run's own
|
|
63
|
+
`/review-pr` agents find" — not "also adopt every other open finding already sitting in the repo."
|
|
64
|
+
Because `BATCH_T0` is captured at batch start, a bare `cascade` invocation with nothing yet spawned
|
|
65
|
+
legitimately resolves to an **empty set** at Phase 1 — new findings admitted by this mode are picked
|
|
66
|
+
up as they're created via `phase-4-execution.md` Step 4C's mid-run sweep (which uses the same
|
|
67
|
+
`BATCH_T0` anchor), not via this one-shot Phase 1 fetch.
|
|
68
|
+
- **(b) Whole-backlog sweep (explicit opt-in only)** — work every open `review-finding` in the repo,
|
|
69
|
+
regardless of when it was created. This is what the pre-#2628 default silently did on every
|
|
70
|
+
`cascade`/`review-findings`/`findings` invocation. It is still available, but now requires the
|
|
71
|
+
explicit `--include-backlog` flag (e.g. `cascade --include-backlog`, `review-findings
|
|
72
|
+
--include-backlog`) — adding `cascade` to an already-scoped batch never implies it.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# BATCH_T0 was captured at the top of this file (Phase 1, "Batch-Start Timestamp (T0)"),
|
|
76
|
+
# before any resolution ran — reuse it here, do not recapture.
|
|
77
|
+
|
|
78
|
+
# --include-backlog opts into mode (b): the full open review-finding backlog, no created-date
|
|
79
|
+
# filter. Without it (the default, mode (a)), only findings created at/after BATCH_T0 are fetched
|
|
80
|
+
# — i.e. issues this batch itself could plausibly have spawned so far.
|
|
81
|
+
if echo "{ARGUMENTS}" | grep -qE -- '--include-backlog'; then
|
|
82
|
+
CASCADE_MODE="backlog-sweep"
|
|
83
|
+
CASCADE_SEARCH="label:review-finding"
|
|
84
|
+
else
|
|
85
|
+
CASCADE_MODE="run-spawned"
|
|
86
|
+
CASCADE_SEARCH="label:review-finding created:>=${BATCH_T0}"
|
|
87
|
+
fi
|
|
88
|
+
echo "Cascade mode: ${CASCADE_MODE} (search: \"${CASCADE_SEARCH}\")"
|
|
89
|
+
|
|
90
|
+
# Fetch matching open review-finding issues, including body (needed for the generation check
|
|
91
|
+
# below — unlike a plain label search, we cannot skip straight to {number,title,labels,milestone}
|
|
92
|
+
# here). --search (not --label) is required to combine the label filter with the created-date
|
|
93
|
+
# filter — verified working: `gh issue list --state open --search "label:review-finding
|
|
94
|
+
# created:>=$T0"`.
|
|
95
|
+
CASCADE_CANDIDATES=$(gh issue list {GH_FLAG} --state open --search "${CASCADE_SEARCH}" --limit 500 \
|
|
96
|
+
--json number,title,labels,milestone,body)
|
|
97
|
+
|
|
98
|
+
# --- Cascade admission policy resolution (forge#2234) ---
|
|
99
|
+
# orchestration.cascade.max_generation is the config-driven, granular successor to the
|
|
100
|
+
# pre-#2234 --include-deferred/--allow-gen2 flags: instead of an all-or-nothing "admit
|
|
101
|
+
# every deferred generation," an operator can say "admit up to generation 3" and no
|
|
102
|
+
# further. Preset table mirrors phase-4-execution.md's Step 4A.pre resolution exactly
|
|
103
|
+
# (see bin/engine/admission.mjs for the typed, unit-tested reference implementation).
|
|
104
|
+
CASCADE_POLICY_NAME=$(yq '.orchestration.cascade.policy // "balanced"' forge.yaml 2>/dev/null || echo "balanced")
|
|
105
|
+
[ "$CASCADE_POLICY_NAME" = "null" ] && CASCADE_POLICY_NAME="balanced"
|
|
106
|
+
case "$CASCADE_POLICY_NAME" in
|
|
107
|
+
all) PRESET_MAX_GEN="unlimited" ;;
|
|
108
|
+
conservative|balanced) PRESET_MAX_GEN=1 ;;
|
|
109
|
+
*) echo "WARNING: forge.yaml → orchestration.cascade.policy \"${CASCADE_POLICY_NAME}\" is not one of: all, balanced, conservative — falling back to \"balanced\""
|
|
110
|
+
CASCADE_POLICY_NAME="balanced"; PRESET_MAX_GEN=1 ;;
|
|
111
|
+
esac
|
|
112
|
+
MAX_GENERATION=$(yq ".orchestration.cascade.max_generation // \"${PRESET_MAX_GEN}\"" forge.yaml 2>/dev/null || echo "$PRESET_MAX_GEN")
|
|
113
|
+
[ "$MAX_GENERATION" = "null" ] && MAX_GENERATION="$PRESET_MAX_GEN"
|
|
114
|
+
if [ "$MAX_GENERATION" != "unlimited" ] && ! echo "$MAX_GENERATION" | grep -qE '^[1-9][0-9]*$'; then
|
|
115
|
+
echo "WARNING: forge.yaml → orchestration.cascade.max_generation is not a positive integer or \"unlimited\" (\"${MAX_GENERATION}\") — falling back to default ${PRESET_MAX_GEN}"
|
|
116
|
+
MAX_GENERATION="$PRESET_MAX_GEN"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
# Pre-#2234 CLI override — still honored for this resolve step specifically. Either flag
|
|
120
|
+
# forces MAX_GENERATION to "unlimited" for this run only, taking precedence over both the
|
|
121
|
+
# preset and any explicit orchestration.cascade.max_generation value (a human typing
|
|
122
|
+
# --allow-gen2 at the CLI is asking for everything, right now, overriding config).
|
|
123
|
+
echo "{ARGUMENTS}" | grep -qE -- '--include-deferred|--allow-gen2' && MAX_GENERATION="unlimited"
|
|
124
|
+
|
|
125
|
+
echo "Cascade resolve: policy=${CASCADE_POLICY_NAME} max_generation=${MAX_GENERATION} (forge.yaml → orchestration.cascade.max_generation; --include-deferred/--allow-gen2 forces unlimited)"
|
|
126
|
+
# --- End cascade admission policy resolution ---
|
|
127
|
+
|
|
128
|
+
# Generation depth — walks the "spawned from issue #N" / "source issue #N" chain up to
|
|
129
|
+
# MAX_HOPS times, counting how many review-finding-labeled ancestors precede this finding.
|
|
130
|
+
# Generation 1 = not spawned from a review-finding (an original issue). Generation 2 = spawned
|
|
131
|
+
# from a review-finding. Generation 3 = spawned from a finding that was itself spawned from a
|
|
132
|
+
# review-finding. This numeric walk (rather than the pre-#2234 single-hop boolean) is what
|
|
133
|
+
# makes "admit gen-2, stop at gen-3" (max_generation: 3) expressible — a binary flag cannot
|
|
134
|
+
# say that; it can only say "admit everything deferred" or "admit nothing deferred." Bounded
|
|
135
|
+
# at MAX_HOPS to guard against a malformed/cyclic reference chain.
|
|
136
|
+
MAX_HOPS=10
|
|
137
|
+
compute_generation() {
|
|
138
|
+
local body="$1"
|
|
139
|
+
local generation=1
|
|
140
|
+
local hops=0
|
|
141
|
+
while [ "$hops" -lt "$MAX_HOPS" ]; do
|
|
142
|
+
# Portable two-step extraction (grep -E, not -P/PCRE — macOS BSD grep lacks \K support):
|
|
143
|
+
# first match the whole "spawned from issue #N" / "source issue: #N" phrase, then pull
|
|
144
|
+
# the trailing digits off that match.
|
|
145
|
+
local source_num
|
|
146
|
+
source_num=$(echo "$body" | grep -ioE 'spawned from issue #[0-9]+|source issue[: #]+[0-9]+' | head -1 | grep -oE '[0-9]+$')
|
|
147
|
+
[ -z "$source_num" ] && break
|
|
148
|
+
local source_data
|
|
149
|
+
source_data=$(gh issue view "$source_num" {GH_FLAG} --json labels,body 2>/dev/null) || break
|
|
150
|
+
echo "$source_data" | jq -e '[.labels[].name] | index("review-finding")' >/dev/null 2>&1 || break
|
|
151
|
+
generation=$((generation + 1))
|
|
152
|
+
body=$(echo "$source_data" | jq -r '.body')
|
|
153
|
+
hops=$((hops + 1))
|
|
154
|
+
done
|
|
155
|
+
echo "$generation"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
CASCADE_RESOLVED="[]"
|
|
159
|
+
echo "$CASCADE_CANDIDATES" | jq -c '.[]' | while IFS= read -r FINDING; do
|
|
160
|
+
FINDING_BODY=$(echo "$FINDING" | jq -r '.body')
|
|
161
|
+
GENERATION=$(compute_generation "$FINDING_BODY")
|
|
162
|
+
if [ "$MAX_GENERATION" = "unlimited" ] || [ "$GENERATION" -le "$MAX_GENERATION" ]; then
|
|
163
|
+
echo "$FINDING" | jq --argjson gen "$GENERATION" '{number, title, labels: [.labels[].name], milestone: .milestone.title, generation: $gen}'
|
|
164
|
+
fi
|
|
165
|
+
done
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Two independent filters apply, in sequence**: first the T0-vs-backlog *time* filter (`CASCADE_SEARCH` above — which issues are even fetched), then the generation *depth* filter below (which of the fetched issues are admitted). They answer different questions — "how far back in time" vs. "how many cascade hops deep" — and either can be widened independently of the other.
|
|
169
|
+
|
|
170
|
+
**Generation > `max_generation` findings are excluded by default** by the loop above — a `review-finding` issue whose *source* chain is deeper than `orchestration.cascade.max_generation` (default: 1, i.e. generation ≥ 2 excluded — the pre-#2234 behavior, unchanged when the section is absent) is normally deferred permanently (`PERMANENT_DEFERRED`) by Step 4C's own absolute check. That cap is an **autonomy guard**, not a human-request guard: it exists to stop an unattended run from cascading forever, not to block an operator who explicitly asked for this exact bucket of work. See `phase-4-execution.md` Step 4C rule 1 and the reworded anti-pattern note for the full rationale.
|
|
171
|
+
|
|
172
|
+
This resolve step is a human-requested entry point (the operator typed `cascade`/`review-findings`/`findings` directly), so it honors explicit overrides for both filters:
|
|
173
|
+
|
|
174
|
+
- `--include-backlog` (forge#2628): widens the **time** filter — mode (b) above, whole open backlog regardless of `BATCH_T0`. Independent of the generation-depth flags below; combine both to get "everything, at every depth" (e.g. `cascade --include-backlog --allow-gen2`).
|
|
175
|
+
- `--include-deferred` or `--allow-gen2` appended to the input (e.g. `cascade --allow-gen2`, `review-findings --include-deferred`): widens the **depth** filter — forces `MAX_GENERATION="unlimited"` above, admitting every generation into the resolved set for this run. Without either flag, `orchestration.cascade.max_generation` (default 1) governs how deep the resolved set reaches — the flags/config only change what this **explicit** request is allowed to touch; they do not relax Step 4C's autonomous behavior for anything spawned *during* this run (see "Recursion safety" below).
|
|
176
|
+
- **`orchestration.cascade.max_generation`** (forge#2234) is the config-driven successor to the CLI depth flags above and supports a granularity the flags cannot: `max_generation: 3` admits generations 1-3 and stops at 4, expressing "admit gen-2, stop at gen-3" directly — something an all-or-nothing `--allow-gen2` flag could never say. The flags remain available and, when passed, override config with `unlimited` for that one invocation. There is no equivalent config-driven lever for the time filter — `--include-backlog` is the only way to widen it, deliberately, since a config default that silently re-adopts the whole backlog would reintroduce exactly the bug this section fixes.
|
|
177
|
+
|
|
178
|
+
**Recursion safety (unchanged)**: findings spawned *during* this run by its own sweep agents are still never re-swept, regardless of whether `--include-backlog`/`--include-deferred`/`--allow-gen2`/`orchestration.cascade.max_generation` was in effect at resolve time — see `phase-4-execution.md` Step 4C rules and the recursion-safety note near the anti-patterns list. The overrides above only widen what this one resolve step admits from the *pre-existing* open issue set; they have no effect on Step 4C's in-run admission logic.
|
|
179
|
+
|
|
180
|
+
### Near-Miss Suggestion
|
|
181
|
+
|
|
182
|
+
<!-- Added: forge#2231 -->
|
|
183
|
+
|
|
184
|
+
When a bare `<slug>` (no keyword) resolves to **zero** issues via both the milestone lookup and the label-search fallback, do not silently return an empty set. Fetch the repo's label list and suggest the closest matching label(s) instead:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
ALL_LABELS=$(gh label list {GH_FLAG} --json name --jq '.[].name')
|
|
188
|
+
# Simple substring/prefix match against the attempted slug — no fuzzy-matching library required.
|
|
189
|
+
NEAR_MISS=$(echo "$ALL_LABELS" | grep -iE "${SLUG:0:4}" || true)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Report back to the caller: `No milestone or label matched "{SLUG}". Did you mean: {NEAR_MISS list, or "review-finding"/"batch" if SLUG resembles "cascade"}?` This directly fixes the case reported in #2231 — `cascade` is not itself a label in most repos (the only cascade-adjacent labels are `review-finding` and `batch`), so a bare `cascade` slug previously resolved to nothing with no feedback. Note that `cascade`/`review-findings`/`findings` are now handled by the dedicated resolution rule above and never reach this bare-slug fallback path at all — this near-miss path remains for any other unmatched slug.
|
|
193
|
+
|
|
194
|
+
### Predicate Persistence (literal set vs. standing query)
|
|
195
|
+
|
|
196
|
+
<!-- Added: forge#2236 -->
|
|
197
|
+
|
|
198
|
+
Phase 1 resolves `$ARGUMENTS` to a concrete issue-number list exactly once, at T0, and that list is currently frozen for the rest of the run (`phase-4-execution.md`: "Phase 1 only runs once, at the start."). Of the input patterns above, only `#1 #2 #3` / `1 2 3` (optionally repo-prefixed) is genuinely a one-time literal set — every other row (`milestone <slug>`, `next <N>`, `next <N> all-repos`, `fast-lane`, `priority:P0`/`priority:P1`, `mcp:fast`/`n8n:next 3`, a bare `<slug>`) is a **standing query**: the predicate ("all open P0s", "this milestone") is the caller's actual intent, not the specific numbers it happened to resolve to at T0.
|
|
199
|
+
|
|
200
|
+
Classify `$ARGUMENTS` and persist that classification alongside the resolved issue-number list, so Phase 4 (`phase-4-execution.md` Step 4B) can decide whether to re-run the query later in the batch:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
node -e '
|
|
204
|
+
import("{REPO_PATH}/bin/engine/resolve.mjs").then(({ classifyInputPattern }) => {
|
|
205
|
+
console.log(JSON.stringify(classifyInputPattern(process.argv[1])));
|
|
206
|
+
}, () => process.exit(0));
|
|
207
|
+
' "$ARGUMENTS"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This mirrors `bin/engine/resolve.mjs`'s `classifyInputPattern` — a typed, unit-tested reference implementation of the same literal-vs-query rule table shown here (see that file's docstring). As with `admission.mjs` for cascade policy, the two must stay in sync by hand: any change to which patterns count as `literal` vs `query` in this table must be mirrored in `resolve.mjs`, and vice versa.
|
|
211
|
+
|
|
212
|
+
Persist the result (`kind`, `pattern`, `args`) next to the resolved issue-number list in the batch's in-memory/report state — e.g. `ORIGINATING_QUERY_KIND`, `ORIGINATING_QUERY_PATTERN`, `ORIGINATING_QUERY_ARGS`. Phase 4's re-resolution step (see `phase-4-execution.md` Step 4B) reads these three values; it never re-derives them from `$ARGUMENTS` a second time. If `kind` is `literal`, Phase 4 MUST NOT attempt to re-resolve — the list of numbers already resolved here IS the complete intent.
|
|
213
|
+
|
|
214
|
+
**`BATCH_T0` is persisted the same way** (forge#2628) — alongside these three values, not as a separate mechanism. Phase 4's Step 4A.pre reads the persisted `BATCH_T0` for Step 4C's run-spawned-cascade time filter (see that section); it must not capture a fresh "now" of its own, which would silently widen the admitted window past what this run actually spawned between Phase 1 and Phase 4's start.
|
|
25
215
|
|
|
26
216
|
### Fetch the issues
|
|
27
217
|
|
|
@@ -42,7 +232,11 @@ gh issue view {NUMBER} -R {SATELLITE_REPO} --json number,title,labels,state,mile
|
|
|
42
232
|
gh issue list {GH_FLAG} --state open --limit {N} --json number,title,labels,milestone
|
|
43
233
|
# For each satellite in forge.yaml → repos.satellites:
|
|
44
234
|
gh issue list -R {SATELLITE_REPO} --state open --limit 500 --json number,title,labels,milestone
|
|
45
|
-
# Combine, sort by priority, take top N
|
|
235
|
+
# Combine, sort by priority, take top N.
|
|
236
|
+
# Priority rank when sorting: read each issue's labels and normalize to bare P<n> form —
|
|
237
|
+
# `priority:P<n>` and bare `P<n>` both resolve to the same rank (see "Priority label schema"
|
|
238
|
+
# note under P3 batching below); an issue with neither label form sorts last, not first, and
|
|
239
|
+
# should be logged as untriaged rather than silently treated as top priority. <!-- Added: forge#2232 -->
|
|
46
240
|
```
|
|
47
241
|
|
|
48
242
|
**Count validation**: After fetching issues, log the count returned. If a milestone query returns exactly 30 issues, warn the user: "WARNING: Exactly 30 issues returned — gh CLI default may have truncated results. Re-running with --limit 500 is recommended." (The --limit 500 above prevents this for standard runs, but verify if the count seems suspiciously low relative to milestone expectations.)
|
|
@@ -61,15 +255,107 @@ Remove issues that should NOT be worked on:
|
|
|
61
255
|
|
|
62
256
|
If a `workflow:decomposed` issue is found, automatically expand it to its open sub-issues instead.
|
|
63
257
|
|
|
258
|
+
### Source-PR Triage Hint (pre-flight, non-binding)
|
|
259
|
+
|
|
260
|
+
<!-- Added: forge#2351 -->
|
|
261
|
+
|
|
262
|
+
**This is a triage hint for the operator and the dispatched investigation agent — it is NEVER an automated verdict.** It must never cause an issue to be closed, skipped, or excluded from dispatch. See "CRITICAL: No duplicate detection at orchestrator level" below — that rule's spirit ("only a `/work-on` investigation agent, after reading the actual code, can determine validity") applies identically here: a closed-unmerged source PR is a reason to look closer, not a reason to skip looking at all.
|
|
263
|
+
|
|
264
|
+
**Why this exists**: `staging-review`/`review-finding` issues carry a `**Source**: PR #{N}` citation in their body (e.g. `**Source**: PR #2337 — Deploy: staging → main`, or `**Source**: PR #2337 | Agent: material-change | Confidence: ... | Severity: ...` — see `review-pr-staging.md` and `review-pr.md` for the citation format). When that source PR closed without merging, the finding's premise ("this change lands on staging/main") may or may not still hold — resolving the PR's state ONCE per distinct PR number, before dispatch, costs one `gh pr view` call and gives the operator and the investigation agent a head start, without ever deciding the outcome.
|
|
265
|
+
|
|
266
|
+
**Two counterexamples from the same 2026-07-17 run prove why this must stay a hint, never a skip:**
|
|
267
|
+
- **#2339 / #2342** — source PR #2337 closed unmerged; the flagged change genuinely never landed by any route (`git log -S` found zero commits for #2339's flagged line; the widening commit in #2342 is not an ancestor of `origin/main`). The hint would have been correct here.
|
|
268
|
+
- **#2346 / #2261 (the load-bearing counterexample)** — source PR #2337 also closed unmerged for #2346, but the flagged code reached `staging` anyway via a **different, independently-merged PR (#2261, commit `90376f5`)** — verified via `git merge-base --is-ancestor 90376f5 origin/staging`. #2346 correctly ended at `needs-human`, not `invalid`. A hint that had been treated as a verdict ("source PR never merged → close") would have been **factually wrong** here — sibling PRs can and do reintroduce or independently land the same code by a different route. **The rule to encode: "the source PR never merged" does NOT imply "the finding is moot."**
|
|
269
|
+
|
|
270
|
+
**Resolve once per distinct PR number, not once per issue** (many findings from one staging review cite the same source PR):
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
# Fetch staging-review / review-finding issues from the already-resolved issue set.
|
|
274
|
+
# Follows this file's per-issue `gh issue view` convention (see Step 3A/3B in
|
|
275
|
+
# phase-3-dependency.md) rather than assuming a pre-materialized bulk JSON variable.
|
|
276
|
+
declare -A SOURCE_PR_STATE_CACHE # PR number -> "state|mergedAt" (cached, resolved once per PR)
|
|
277
|
+
declare -A ISSUE_SOURCE_PR # issue number -> cited PR number (or empty if no citation found)
|
|
278
|
+
declare -A ISSUE_SOURCE_PR_STATE # issue number -> "OPEN" | "MERGED" | "CLOSED_UNMERGED" | "unknown"
|
|
279
|
+
declare -A ISSUE_LIKELY_MOOT # issue number -> "yes" | "unknown"
|
|
280
|
+
|
|
281
|
+
for NUM in {issue_numbers}; do
|
|
282
|
+
ISSUE_JSON_NUM=$(gh issue view "$NUM" {GH_FLAG} --json labels,body 2>/dev/null)
|
|
283
|
+
LABELS_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '[.labels[].name] | join(",")' 2>/dev/null || echo "")
|
|
284
|
+
case "$LABELS_NUM" in
|
|
285
|
+
*staging-review*|*review-finding*) : ;;
|
|
286
|
+
*) ISSUE_LIKELY_MOOT[$NUM]="unknown"; continue ;;
|
|
287
|
+
esac
|
|
288
|
+
|
|
289
|
+
ISSUE_BODY_NUM=$(echo "$ISSUE_JSON_NUM" | jq -r '.body // ""' 2>/dev/null || echo "")
|
|
290
|
+
|
|
291
|
+
# Extract the cited PR number. Anchored on the literal "**Source**: PR #" prefix so it
|
|
292
|
+
# matches both observed formats ("PR #2337 — Deploy: staging → main" and
|
|
293
|
+
# "PR #2337 | Agent: material-change | ..."); capture stops at the first non-digit.
|
|
294
|
+
PRNUM=$(echo "$ISSUE_BODY_NUM" | grep -oE '\*\*Source\*\*: PR #[0-9]+' | head -1 | grep -oE '[0-9]+' | head -1)
|
|
295
|
+
# Digits-only guard (defense in depth — the regex above already restricts to [0-9]+,
|
|
296
|
+
# but re-validate before interpolating into any gh command; see #1833 — an unsanitized
|
|
297
|
+
# issue-body-derived value was templated verbatim into a gh command elsewhere in this file).
|
|
298
|
+
if ! echo "$PRNUM" | grep -qE '^[0-9]+$'; then
|
|
299
|
+
ISSUE_SOURCE_PR[$NUM]=""
|
|
300
|
+
ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
|
|
301
|
+
ISSUE_LIKELY_MOOT[$NUM]="unknown"
|
|
302
|
+
continue
|
|
303
|
+
fi
|
|
304
|
+
ISSUE_SOURCE_PR[$NUM]="$PRNUM"
|
|
305
|
+
|
|
306
|
+
# Resolve this PR's state ONCE — cache hit skips the gh call entirely for every
|
|
307
|
+
# subsequent issue citing the same PR number.
|
|
308
|
+
if [ -z "${SOURCE_PR_STATE_CACHE[$PRNUM]+x}" ]; then
|
|
309
|
+
PR_JSON=$(gh pr view "$PRNUM" {GH_FLAG} --json state,mergedAt 2>/dev/null)
|
|
310
|
+
if [ -n "$PR_JSON" ]; then
|
|
311
|
+
PR_STATE=$(echo "$PR_JSON" | jq -r '.state')
|
|
312
|
+
PR_MERGED_AT=$(echo "$PR_JSON" | jq -r '.mergedAt // "null"')
|
|
313
|
+
SOURCE_PR_STATE_CACHE[$PRNUM]="${PR_STATE}|${PR_MERGED_AT}"
|
|
314
|
+
else
|
|
315
|
+
# gh pr view failed (rate limit, deleted PR, network) — non-fatal, mark unknown and continue.
|
|
316
|
+
SOURCE_PR_STATE_CACHE[$PRNUM]="unknown|unknown"
|
|
317
|
+
fi
|
|
318
|
+
fi
|
|
319
|
+
|
|
320
|
+
CACHED="${SOURCE_PR_STATE_CACHE[$PRNUM]}"
|
|
321
|
+
C_STATE="${CACHED%%|*}"
|
|
322
|
+
C_MERGED="${CACHED##*|}"
|
|
323
|
+
|
|
324
|
+
if [ "$C_STATE" = "unknown" ]; then
|
|
325
|
+
ISSUE_SOURCE_PR_STATE[$NUM]="unknown"
|
|
326
|
+
ISSUE_LIKELY_MOOT[$NUM]="unknown"
|
|
327
|
+
elif [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" = "null" ]; then
|
|
328
|
+
ISSUE_SOURCE_PR_STATE[$NUM]="CLOSED_UNMERGED"
|
|
329
|
+
ISSUE_LIKELY_MOOT[$NUM]="yes"
|
|
330
|
+
elif [ "$C_STATE" = "MERGED" ] || { [ "$C_STATE" = "CLOSED" ] && [ "$C_MERGED" != "null" ]; }; then
|
|
331
|
+
ISSUE_SOURCE_PR_STATE[$NUM]="MERGED"
|
|
332
|
+
ISSUE_LIKELY_MOOT[$NUM]="unknown"
|
|
333
|
+
else
|
|
334
|
+
ISSUE_SOURCE_PR_STATE[$NUM]="OPEN"
|
|
335
|
+
ISSUE_LIKELY_MOOT[$NUM]="unknown"
|
|
336
|
+
fi
|
|
337
|
+
done
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
**Output arrays** (consumed downstream — never re-derived): `ISSUE_LIKELY_MOOT[$NUM]` (`yes`/`unknown`), `ISSUE_SOURCE_PR[$NUM]` (cited PR number or empty), `ISSUE_SOURCE_PR_STATE[$NUM]` (`OPEN`/`MERGED`/`CLOSED_UNMERGED`/`unknown`).
|
|
341
|
+
|
|
342
|
+
- `commands/orchestrate/phase-3-dependency.md` Step 3E's Dependency Graph plan table dereferences `ISSUE_LIKELY_MOOT[$NUM]` as a `Source-PR Hint` column — informational only, never affecting a row's `Status`.
|
|
343
|
+
- `commands/orchestrate/phase-4-execution.md`'s Agent-spawn template threads `ISSUE_LIKELY_MOOT[$NUM]` / `ISSUE_SOURCE_PR[$NUM]` / `ISSUE_SOURCE_PR_STATE[$NUM]` into the dispatched agent's initial context as `{SOURCE_PR_HINT_CONTEXT}`, explicitly framed as a starting point to check first — never as a reason to skip investigating.
|
|
344
|
+
|
|
345
|
+
**MUST NOT**: This hint MUST NOT be used, anywhere in this pipeline, to auto-close an issue, auto-skip it from dispatch, exclude it from the resolved issue set, or substitute for the investigation agent's own evidence-based verdict. Every `likely-moot: yes` issue is still dispatched into a full `/work-on` pipeline exactly like every other issue in the resolved set — the "CRITICAL: No duplicate detection at orchestrator level" rule below governs this identically.
|
|
346
|
+
|
|
64
347
|
### P3 Review-Finding Batching (deterministic grouping rule)
|
|
65
348
|
|
|
66
349
|
<!-- Added: forge#1333 -->
|
|
67
350
|
<!-- Extended: forge#1818 — default-batchable eligibility, surface-area grouping, lowered same-file threshold -->
|
|
351
|
+
<!-- Extended: forge#2232 — priority-label schema tolerance (bare P<n> vs priority:P<n>) -->
|
|
68
352
|
|
|
69
353
|
Before finalizing the issue set, apply the P3 batching rule to reduce full-pipeline overhead on low-severity review findings.
|
|
70
354
|
|
|
355
|
+
**Priority label schema**: ForgeDock's own issue creator (`review-pr.md`) writes only the canonical `priority:P<n>` label form. Some repos this pipeline operates against (issues opened externally, imported, or predating ForgeDock adoption) instead carry a bare `P<n>` label with no `priority:` prefix. Every priority-read check in this section — and its mirrors in `phase-4-execution.md` and `cleanup.md` — MUST accept both forms. `priority:P<n>` wins when (unusually) both are present on the same issue. Issue-*creation* call sites (the batch-issue creation below) are unaffected and keep writing canonical `priority:P<n>` only — there is no case where this pipeline needs to *write* the bare form. <!-- Added: forge#2232 -->
|
|
356
|
+
|
|
71
357
|
**Trigger conditions** (default-batchable — no opt-in marker required):
|
|
72
|
-
1. The issue has
|
|
358
|
+
1. The issue has label `review-finding` + a P3 priority label (`priority:P3` or bare `P3`)
|
|
73
359
|
2. The issue does NOT match any Safety exclusion below
|
|
74
360
|
|
|
75
361
|
The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer REQUIRED for eligibility — a `review-finding`+`priority:P3` issue is batchable by default unless explicitly excluded. This closes the gap where cascade-spawned findings that never carried the marker sat un-batched indefinitely. <!-- Added: forge#1818 -->
|
|
@@ -82,14 +368,49 @@ These exclusions apply regardless of priority: P1/P2 findings are already never
|
|
|
82
368
|
|
|
83
369
|
**Grouping algorithm (surface area — same file first, leaf directory as broader fallback):** <!-- Changed: forge#1818 — was domain-only -->
|
|
84
370
|
```bash
|
|
85
|
-
# Fetch all open batchable P3 issues (default-batchable; marker no longer required)
|
|
371
|
+
# Fetch all open batchable P3 issues (default-batchable; marker no longer required).
|
|
372
|
+
# NOTE: `--label` is an exact-match GH filter and cannot OR "priority:P3" with bare "P3" in
|
|
373
|
+
# one query, so the P3 test moves into the jq predicate below (schema-tolerant, forge#2232).
|
|
374
|
+
# Only "review-finding" stays in the --label filter.
|
|
375
|
+
# Safety-exclusion keyword alternation, shared verbatim across all three
|
|
376
|
+
# mirrored sites (this file, phase-4-execution.md, cleanup.md) — see forge#2423.
|
|
377
|
+
# Word-boundary anchored so it matches whole terms only, not substrings:
|
|
378
|
+
# `authority_source`/`authoritative`/`author`/`authored` no longer trip `auth`.
|
|
379
|
+
# `authentication|authorization|authn|authz` are listed explicitly so real
|
|
380
|
+
# auth-domain findings that never use the bare word "auth" still exclude.
|
|
86
381
|
BATCHABLE_P3=$(gh issue list {GH_FLAG} \
|
|
87
382
|
--state open \
|
|
88
|
-
--label "review-finding
|
|
383
|
+
--label "review-finding" \
|
|
89
384
|
--limit 500 \
|
|
90
385
|
--json number,title,body,labels \
|
|
91
|
-
--jq '.[] | select(
|
|
92
|
-
| select(.
|
|
386
|
+
--jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
|
|
387
|
+
| select((.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i")) | not)
|
|
388
|
+
# Strip the review-finding template's attribution boilerplate
|
|
389
|
+
# (**Confidence**/**Severity**/**Review comment** — see forge#2477
|
|
390
|
+
# note below for why **Source**/**Agent** are deliberately excluded
|
|
391
|
+
# from this list; see review-pr.md L1691-1719 for the full template)
|
|
392
|
+
# before scanning the body — otherwise a finding is excluded because
|
|
393
|
+
# of who reviewed it (e.g. "**Agent**: Security (...)") rather than
|
|
394
|
+
# what it is actually about. <!-- forge#2423 -->
|
|
395
|
+
# Each alternative is anchored to the field's real generator-output shape
|
|
396
|
+
# (enum for Confidence/Severity, URL for Review comment) rather than a
|
|
397
|
+
# bare label-prefix + `.*$` — matching on label shape alone lets
|
|
398
|
+
# attacker-controlled body text on one of these lines get stripped
|
|
399
|
+
# along with the label, smuggling banned keywords past the scan below.
|
|
400
|
+
# Source/Agent are deliberately NOT stripped: both hold genuinely
|
|
401
|
+
# free-text generator output (a PR title; an agent's self-description)
|
|
402
|
+
# with no fixed vocabulary, so no shape bound can distinguish
|
|
403
|
+
# legitimate attribution from attacker-authored payload placed in the
|
|
404
|
+
# same position — a length-bounded free-text alternative for either
|
|
405
|
+
# field re-opens the exact smuggling gap this fix closes (an attacker
|
|
406
|
+
# need only prefix their payload with a fake "PR #N — " or agent name
|
|
407
|
+
# to satisfy the bound). Leaving them unstripped trades a narrow,
|
|
408
|
+
# already-known false-positive (forge#2423's Agent-line case; a P3
|
|
409
|
+
# finding whose Source/Agent text happens to mention a domain keyword
|
|
410
|
+
# is not auto-batched) for closing a real bypass — the safe direction
|
|
411
|
+
# for a security-relevant exclusion. <!-- forge#2477 -->
|
|
412
|
+
| (.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) as $stripped_body
|
|
413
|
+
| select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i") | not)
|
|
93
414
|
| select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
|
|
94
415
|
|
|
95
416
|
# Surface area = the exact affected file path listed first under "## Affected Files" (primary grouping key).
|
|
@@ -98,6 +419,13 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
|
|
|
98
419
|
# "web/src/app/billing/page.tsx" → EXCLUDED (billing path)
|
|
99
420
|
# "commands/orchestrate/phase-1-resolve.md" → file "commands/orchestrate/phase-1-resolve.md", leaf-dir "commands/orchestrate"
|
|
100
421
|
# "commands/review-pr.md" → file "commands/review-pr.md", leaf-dir "commands"
|
|
422
|
+
|
|
423
|
+
# Safety-exclusion worked examples (forge#2423 — both false-positive classes
|
|
424
|
+
# fixed, true-positive case still excluded):
|
|
425
|
+
# Title "authority_source docstring fix" → NOT excluded (was: excluded — bare-substring "auth" match)
|
|
426
|
+
# Body: "...**Agent**: Security (General Security & Quality Scan)..." with a
|
|
427
|
+
# ## Problem about a stale docstring count → NOT excluded (was: excluded — reviewer's own name matched "security")
|
|
428
|
+
# Title "fix auth bypass in login flow" → still excluded (genuine auth finding — true positive preserved)
|
|
101
429
|
```
|
|
102
430
|
|
|
103
431
|
**Batch creation rule (two-tier threshold):** <!-- Changed: forge#1818 — added lower same-file tier -->
|
|
@@ -109,8 +437,11 @@ BATCHABLE_P3=$(gh issue list {GH_FLAG} \
|
|
|
109
437
|
|
|
110
438
|
**Route batch issue creation through `/issue`'s programmatic invocation contract** (`commands/issue.md`, added #2085) instead of calling `gh issue create` directly — this gets dedup (Phase 2D) and mandatory-section body validation (Phase 3F) for free. <!-- Changed: forge#2086 — route through /issue create-hook -->
|
|
111
439
|
|
|
440
|
+
**Exclude the cluster's own member issues from the dedup candidate set (MANDATORY):** A batch title necessarily restates its member findings' subject matter by construction — it is a deliberate supersede, not an accidental duplicate — so without exclusion it always collides with its own members under Phase 2D's token-overlap algorithm, making the batching rule unfollowable. Pass the cluster's member issue numbers via `/issue`'s `--exclude` flag (`scripts/issue-dedup.sh --exclude`, forge#2432) so Phase 2D only fires on a **genuine non-member duplicate** — some other open issue this batch was never meant to cover. This is NOT a `--force` equivalent: it narrows the candidate set, it does not disable the gate. <!-- Added: forge#2432 -->
|
|
441
|
+
|
|
112
442
|
```bash
|
|
113
443
|
SAFE_SURFACE_AREA=$(printf '%s' "{SURFACE_AREA}" | tr -cd 'A-Za-z0-9._/-')
|
|
444
|
+
MEMBER_LIST="{N1},{N2},{N3},..." # comma-joined member issue numbers for this cluster (e.g. "2422,2424")
|
|
114
445
|
BATCH_BODY_FILE="$(mktemp)"
|
|
115
446
|
cat > "$BATCH_BODY_FILE" <<'BATCH_EOF'
|
|
116
447
|
## Problem
|
|
@@ -139,7 +470,7 @@ BATCH_EOF
|
|
|
139
470
|
```
|
|
140
471
|
|
|
141
472
|
```
|
|
142
|
-
ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (batch #{BATCH_N})\" --body-file \"${BATCH_BODY_FILE}\" --label \"review-finding\" --label \"priority:P3\" --label \"batch\""))
|
|
473
|
+
ISSUE_SKILL_OUTPUT=$(Skill(skill="issue", args="--title \"fix(batch): P3 review findings — ${SAFE_SURFACE_AREA} (batch #{BATCH_N})\" --body-file \"${BATCH_BODY_FILE}\" --label \"review-finding\" --label \"priority:P3\" --label \"batch\" --exclude \"${MEMBER_LIST}\""))
|
|
143
474
|
```
|
|
144
475
|
|
|
145
476
|
**Extract the created batch issue number from the Skill output** (see `commands/issue.md` Phase 4C/4E — it echoes `Created: {url}` and reports `**#{NUMBER}**: {title}`):
|
|
@@ -149,7 +480,13 @@ BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE 'issues/[0-9]+' | head -
|
|
|
149
480
|
[ -z "$BATCH_ISSUE_NUM" ] && BATCH_ISSUE_NUM=$(echo "$ISSUE_SKILL_OUTPUT" | grep -oE '\*\*#[0-9]+\*\*' | head -1 | grep -oE '[0-9]+')
|
|
150
481
|
|
|
151
482
|
if [ -z "$BATCH_ISSUE_NUM" ]; then
|
|
152
|
-
|
|
483
|
+
# With --exclude "${MEMBER_LIST}" passed above, Phase 2D no longer fires on the
|
|
484
|
+
# cluster's own declared members — a STOP reaching this point means a GENUINE
|
|
485
|
+
# non-member duplicate was found (some other open issue already covers this
|
|
486
|
+
# exact surface area), or a usage error. Do NOT replace member issues with a
|
|
487
|
+
# batch issue for this cluster; leave the members on the standard individual
|
|
488
|
+
# pipeline instead. <!-- Reworded: forge#2432 -->
|
|
489
|
+
echo "WARNING: /issue did not report a created batch issue number — likely a Phase 2D dedup STOP against a non-member issue (a real duplicate — member exclusion via --exclude \"${MEMBER_LIST}\" is already applied above, so this is not a false positive against the cluster's own members) or a usage error. Do not replace member issues with a batch issue for this cluster; leave the members on the standard individual pipeline instead."
|
|
153
490
|
fi
|
|
154
491
|
```
|
|
155
492
|
|