forgedock 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/batch-runner.mjs +31 -2
- package/bin/engine/admission.mjs +294 -0
- package/bin/engine/admission.test.mjs +265 -0
- package/bin/engine/invariants.mjs +17 -4
- package/bin/engine/phases.mjs +201 -35
- package/bin/engine/resolve.mjs +256 -0
- package/bin/engine/resolve.test.mjs +149 -0
- package/bin/engine/runlog.mjs +11 -1
- package/bin/engine/state.mjs +20 -28
- package/bin/engine-cli.mjs +86 -5
- package/bin/engine.mjs +543 -36
- package/bin/forgedock.mjs +397 -59
- package/bin/hooks/interactive-engine.mjs +50 -24
- package/bin/hooks/pre-tool-use.mjs +191 -13
- package/bin/journey.mjs +586 -44
- package/bin/labels.json +5 -0
- package/bin/runner.mjs +478 -10
- package/bin/watch-utils.mjs +258 -0
- package/commands/cleanup.md +34 -5
- package/commands/issue.md +14 -4
- package/commands/orchestrate/config.md +43 -1
- package/commands/orchestrate/phase-1-resolve.md +348 -11
- package/commands/orchestrate/phase-3-dependency.md +272 -49
- package/commands/orchestrate/phase-4-execution.md +622 -60
- package/commands/orchestrate/phase-5-cleanup.md +2 -0
- package/commands/quality-gate.md +6 -6
- package/commands/review-pr-staging.md +51 -8
- package/commands/review-pr.md +120 -39
- package/commands/upgrade-deps.md +7 -2
- package/commands/work-on/decompose.md +2 -0
- package/commands/work-on/investigate.md +22 -4
- package/commands/work-on/remediate.md +55 -4
- package/commands/work-on/review.md +40 -1
- package/commands/work-on.md +19 -4
- package/docs/CONFIG.md +120 -0
- package/docs/spec/eval-run-result.md +8 -4
- package/docs/spec/label-state-machine.md +9 -0
- package/package.json +2 -2
- package/packages/protocol/src/emit.js +5 -22
- package/packages/protocol/src/html-comment-escape.js +68 -0
- package/packages/protocol/src/index.js +1 -0
- package/packages/protocol/src/parse.js +5 -6
- package/packages/protocol/src/phases.js +105 -0
- package/packages/protocol/src/types.js +17 -0
- package/packages/protocol/src/validate.js +24 -1
- package/scripts/check-admission-parity.mjs +167 -0
- package/scripts/check-command-side-effects.sh +181 -66
- package/scripts/check-phase-registry-drift.mjs +118 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/classify-lane.sh +29 -3
- package/scripts/derive-finding-milestone.sh +184 -0
- package/scripts/doctor-pipeline-state.sh +1 -0
- package/scripts/extract-affected-files.sh +232 -0
- package/scripts/extract-affected-files.test.sh +331 -0
- package/scripts/forge-run.sh +39 -4
- package/scripts/issue-dedup.sh +69 -8
- package/scripts/issue-dedup.test.sh +142 -0
- package/scripts/severity-to-priority.sh +81 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pure, independently-testable helpers for `forgedock watch` (bin/forgedock.mjs).
|
|
6
|
+
*
|
|
7
|
+
* Extracted out of watch()/render() (forge#2235) so the label-derivation,
|
|
8
|
+
* findings-lane classification, priority normalization, satellite-repo
|
|
9
|
+
* parsing, and heartbeat-batching logic can be unit tested without spawning
|
|
10
|
+
* `gh` subprocesses. None of these functions perform I/O themselves — the
|
|
11
|
+
* caller in forgedock.mjs reads files / shells out and passes the results in.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "fs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Conservative built-in fallback for the watched label set, used only when
|
|
18
|
+
* bin/labels.json is missing or unreadable. Historically this was the ONLY
|
|
19
|
+
* label set watch() ever used (forge#2235) — kept here so a broken/missing
|
|
20
|
+
* manifest degrades to the old (narrow but working) behavior instead of
|
|
21
|
+
* watch() going fully blind.
|
|
22
|
+
*/
|
|
23
|
+
export const FALLBACK_WORKFLOW_LABELS = [
|
|
24
|
+
"workflow:investigating",
|
|
25
|
+
"workflow:ready-to-build",
|
|
26
|
+
"workflow:building",
|
|
27
|
+
"workflow:in-review",
|
|
28
|
+
"needs-human",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The label that marks a review-finding issue, watched in its own lane
|
|
33
|
+
* (forge#2235) independent of workflow:* state — a finding is born without
|
|
34
|
+
* any workflow:* label and may never acquire one (deferred/PERMANENT_DEFERRED
|
|
35
|
+
* findings), so it must not depend on the in-flight label set to be visible.
|
|
36
|
+
*/
|
|
37
|
+
export const FINDINGS_LANE_LABEL = "review-finding";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the watched "in-flight" label set from the canonical bin/labels.json
|
|
41
|
+
* manifest instead of a hardcoded literal (forge#2235). Returns every label
|
|
42
|
+
* whose name starts with "workflow:", plus "needs-human" (the terminal-stall
|
|
43
|
+
* pipeline label, not itself prefixed workflow: but part of the same family).
|
|
44
|
+
*
|
|
45
|
+
* Falls back to FALLBACK_WORKFLOW_LABELS if the manifest is absent, unreadable,
|
|
46
|
+
* or malformed, so a broken manifest never makes watch() go silent.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} labelsJsonPath - absolute path to bin/labels.json
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function deriveWorkflowLabels(labelsJsonPath) {
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(labelsJsonPath)) return FALLBACK_WORKFLOW_LABELS;
|
|
54
|
+
const raw = readFileSync(labelsJsonPath, "utf-8");
|
|
55
|
+
const all = JSON.parse(raw);
|
|
56
|
+
if (!Array.isArray(all)) return FALLBACK_WORKFLOW_LABELS;
|
|
57
|
+
const derived = all
|
|
58
|
+
.map((l) => l && l.name)
|
|
59
|
+
.filter((name) => typeof name === "string" && (name.startsWith("workflow:") || name === "needs-human"));
|
|
60
|
+
return derived.length > 0 ? derived : FALLBACK_WORKFLOW_LABELS;
|
|
61
|
+
} catch {
|
|
62
|
+
return FALLBACK_WORKFLOW_LABELS;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Normalize a mixed label array (strings or {name} objects, as returned by
|
|
68
|
+
* `gh issue list --json labels`) into a plain array of name strings.
|
|
69
|
+
* @param {Array<string|{name: string}>} labels
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function labelNames(labels) {
|
|
73
|
+
if (!Array.isArray(labels)) return [];
|
|
74
|
+
return labels.map((l) => (typeof l === "string" ? l : l && l.name)).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Classify a review-finding issue's render status in the findings lane from
|
|
79
|
+
* its full label set (forge#2235). Callers should only invoke this for
|
|
80
|
+
* issues that do NOT carry a workflow:* label — those are in-flight and
|
|
81
|
+
* belong in the main table, not the findings lane, to avoid double-rendering
|
|
82
|
+
* the same issue in two places.
|
|
83
|
+
*
|
|
84
|
+
* - "validated" — confirmed as a real issue (label: validated)
|
|
85
|
+
* - "false-positive" — dismissed (label: false-positive)
|
|
86
|
+
* - "queued" — awaiting human validation (label: needs-validation)
|
|
87
|
+
* - "deferred" — bare review-finding with none of the above; this is
|
|
88
|
+
* the previously-invisible "PERMANENT_DEFERRED" bucket
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<string|{name: string}>} labels
|
|
91
|
+
* @returns {"validated"|"false-positive"|"queued"|"deferred"}
|
|
92
|
+
*/
|
|
93
|
+
export function classifyFindingStatus(labels) {
|
|
94
|
+
const names = labelNames(labels);
|
|
95
|
+
if (names.includes("validated")) return "validated";
|
|
96
|
+
if (names.includes("false-positive")) return "false-positive";
|
|
97
|
+
if (names.includes("needs-validation")) return "queued";
|
|
98
|
+
return "deferred";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* True if the given label set carries any workflow:*-family label (i.e. the
|
|
103
|
+
* issue is "in-flight" per deriveWorkflowLabels()'s family, not just the
|
|
104
|
+
* literal "workflow:" prefix check — needs-human counts too).
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<string|{name: string}>} labels
|
|
107
|
+
* @param {string[]} workflowLabels - the label set returned by deriveWorkflowLabels()
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
export function hasWorkflowLabel(labels, workflowLabels) {
|
|
111
|
+
const names = new Set(labelNames(labels));
|
|
112
|
+
return workflowLabels.some((wl) => names.has(wl));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Normalize a priority label to a short display form ("P0".."P3"), accepting
|
|
117
|
+
* both the canonical `priority:P<n>` form and the bare `P<n>` form used by
|
|
118
|
+
* some consumer repos (forge#2232 established this same dual-form handling
|
|
119
|
+
* for priority extraction elsewhere in the pipeline; forge#2235 applies it to
|
|
120
|
+
* watch's render rows).
|
|
121
|
+
*
|
|
122
|
+
* Canonical `priority:P<n>` wins if both happen to be present.
|
|
123
|
+
*
|
|
124
|
+
* @param {Array<string|{name: string}>} labels
|
|
125
|
+
* @returns {string|null} e.g. "P1", or null if no priority label found
|
|
126
|
+
*/
|
|
127
|
+
export function normalizePriority(labels) {
|
|
128
|
+
const names = labelNames(labels);
|
|
129
|
+
const canonical = names.find((n) => /^priority:P[0-3]$/.test(n));
|
|
130
|
+
if (canonical) return canonical.replace("priority:", "");
|
|
131
|
+
const bare = names.find((n) => /^P[0-3]$/.test(n));
|
|
132
|
+
return bare || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse `repos.satellites[].repo` entries out of a forge.yaml file's raw
|
|
137
|
+
* text, without a YAML parser dependency — consistent with the rest of
|
|
138
|
+
* forgedock.mjs's regex-based forge.yaml reads (resolveLabelsRepo,
|
|
139
|
+
* stall_timeout_minutes, etc.). Returns [] when no `satellites:` section is
|
|
140
|
+
* present, so callers degrade to exactly the previous single-repo behavior.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} forgeYamlText
|
|
143
|
+
* @returns {string[]} "owner/repo" strings
|
|
144
|
+
*/
|
|
145
|
+
export function parseSatelliteRepos(forgeYamlText) {
|
|
146
|
+
if (typeof forgeYamlText !== "string") return [];
|
|
147
|
+
const idx = forgeYamlText.indexOf("satellites:");
|
|
148
|
+
if (idx === -1) return [];
|
|
149
|
+
const tail = forgeYamlText.slice(idx + "satellites:".length);
|
|
150
|
+
// Stop at the next non-indented (top-level) key so an unrelated later
|
|
151
|
+
// section's `repo:` field is never picked up as a satellite.
|
|
152
|
+
const stopMatch = tail.match(/\n[^\s#][^\n]*/);
|
|
153
|
+
const section = stopMatch ? tail.slice(0, stopMatch.index) : tail;
|
|
154
|
+
const repos = [];
|
|
155
|
+
// Anchor to a YAML key boundary (line start, optional indentation, optional
|
|
156
|
+
// `-` list-item marker) so a future suffixed key like `staging_repo:` can
|
|
157
|
+
// never be matched as a trailing substring of an unrelated key (forge#2457).
|
|
158
|
+
const re = /^[ \t]*(?:-[ \t]*)?repo:\s*["']?([^\s"'#]+)["']?/gm;
|
|
159
|
+
let m;
|
|
160
|
+
while ((m = re.exec(section)) !== null) {
|
|
161
|
+
repos.push(m[1]);
|
|
162
|
+
}
|
|
163
|
+
return repos;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Escape a value for safe interpolation inside a double-quoted GraphQL
|
|
168
|
+
* string literal (forge#2307). Backslash MUST be escaped first — escaping
|
|
169
|
+
* `"` before `\` would double-escape the backslashes just introduced by the
|
|
170
|
+
* quote-escaping step. A value with no special characters round-trips
|
|
171
|
+
* unchanged, so this is a no-op for the overwhelming common case (plain
|
|
172
|
+
* alphanumeric owner/repo names).
|
|
173
|
+
*
|
|
174
|
+
* @param {string} value
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function escapeGraphQLString(value) {
|
|
178
|
+
return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build a single GraphQL query batching FORGE:HEARTBEAT comment lookups for
|
|
183
|
+
* many issues into one `gh api graphql` round-trip instead of one REST call
|
|
184
|
+
* per issue (forge#2235 — de-N+1). The caller is responsible for chunking
|
|
185
|
+
* issueNumbers to stay under GraphQL query complexity limits (a chunk size
|
|
186
|
+
* of ~25 is safe).
|
|
187
|
+
*
|
|
188
|
+
* Fetches only the last 20 comments per issue (heartbeats are appended, so
|
|
189
|
+
* the latest one is always near the end) rather than the full comment
|
|
190
|
+
* history, to keep the response small.
|
|
191
|
+
*
|
|
192
|
+
* `owner`/`repo` are escaped (forge#2307) before interpolation into the
|
|
193
|
+
* double-quoted GraphQL string literal so a value containing `"` or `\`
|
|
194
|
+
* cannot break out of the literal and alter the query structure. In
|
|
195
|
+
* practice these values come from trusted local input (forge.yaml or a
|
|
196
|
+
* `--repo` CLI flag — see bin/forgedock.mjs resolveLabelsRepo/watch()), not
|
|
197
|
+
* issue/PR content, but the query builder itself should not depend on that
|
|
198
|
+
* being true forever.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} owner
|
|
201
|
+
* @param {string} repo
|
|
202
|
+
* @param {number[]} issueNumbers
|
|
203
|
+
* @returns {string} GraphQL query document
|
|
204
|
+
*/
|
|
205
|
+
export function buildHeartbeatBatchQuery(owner, repo, issueNumbers) {
|
|
206
|
+
const aliases = issueNumbers
|
|
207
|
+
.map(
|
|
208
|
+
(n, i) =>
|
|
209
|
+
`i${i}: issue(number: ${Number(n)}) { number comments(last: 20) { nodes { body } } }`,
|
|
210
|
+
)
|
|
211
|
+
.join("\n ");
|
|
212
|
+
return `query {
|
|
213
|
+
repository(owner: "${escapeGraphQLString(owner)}", name: "${escapeGraphQLString(repo)}") {
|
|
214
|
+
${aliases}
|
|
215
|
+
}
|
|
216
|
+
}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Extract the latest FORGE:HEARTBEAT comment body per issue number from a
|
|
221
|
+
* parsed `gh api graphql` response built against buildHeartbeatBatchQuery().
|
|
222
|
+
*
|
|
223
|
+
* @param {object} graphqlJson - parsed JSON response from `gh api graphql`
|
|
224
|
+
* @returns {Map<number, string|null>} issue number -> latest heartbeat body, or null if none found
|
|
225
|
+
*/
|
|
226
|
+
export function parseHeartbeatBatchResponse(graphqlJson) {
|
|
227
|
+
const result = new Map();
|
|
228
|
+
const repoData = graphqlJson && graphqlJson.data && graphqlJson.data.repository;
|
|
229
|
+
if (!repoData) return result;
|
|
230
|
+
for (const key of Object.keys(repoData)) {
|
|
231
|
+
const issue = repoData[key];
|
|
232
|
+
if (!issue || typeof issue.number !== "number") continue;
|
|
233
|
+
const nodes = (issue.comments && issue.comments.nodes) || [];
|
|
234
|
+
let heartbeat = null;
|
|
235
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
236
|
+
if (nodes[i] && typeof nodes[i].body === "string" && nodes[i].body.includes("FORGE:HEARTBEAT")) {
|
|
237
|
+
heartbeat = nodes[i].body;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
result.set(issue.number, heartbeat);
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Chunk an array into fixed-size slices (helper for GraphQL batch sizing).
|
|
248
|
+
* @param {Array<*>} arr
|
|
249
|
+
* @param {number} size
|
|
250
|
+
* @returns {Array<Array<*>>}
|
|
251
|
+
*/
|
|
252
|
+
export function chunkArray(arr, size) {
|
|
253
|
+
const out = [];
|
|
254
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
255
|
+
out.push(arr.slice(i, i + size));
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
package/commands/cleanup.md
CHANGED
|
@@ -381,7 +381,7 @@ Do NOT call `gh api ... -X PATCH -f state=closed` on any milestone. This phase i
|
|
|
381
381
|
|
|
382
382
|
### Step 4B.1: Fetch open batchable P3 findings
|
|
383
383
|
|
|
384
|
-
Eligibility is **default-batchable**: any open `review-finding` + `priority:P3` issue qualifies unless excluded below. The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer required — matching `phase-1-resolve.md`'s eligibility rule. <!-- Changed: forge#1828 — marker was previously mandatory -->
|
|
384
|
+
Eligibility is **default-batchable**: any open `review-finding` + a P3 priority label (`priority:P3` or bare `P3` — see `phase-1-resolve.md`'s "Priority label schema" note; some consumer repos label externally/legacy findings with the bare form) issue qualifies unless excluded below. The `<!-- FORGE:BATCHABLE -->` marker (still appended by `review-pr.md` at finding-creation time) is honored when present but is no longer required — matching `phase-1-resolve.md`'s eligibility rule. <!-- Changed: forge#1828 — marker was previously mandatory; forge#2232 — priority-schema tolerance -->
|
|
385
385
|
|
|
386
386
|
```bash
|
|
387
387
|
# Find all open, unbatched P3 review findings. Excludes findings already
|
|
@@ -389,14 +389,43 @@ Eligibility is **default-batchable**: any open `review-finding` + `priority:P3`
|
|
|
389
389
|
# exclusions (security, billing, anti-bot, auth) as the other two batching
|
|
390
390
|
# sites, using the identical jq test() (Oniguruma) patterns so classification
|
|
391
391
|
# cannot diverge between the three sweep locations. <!-- Changed: forge#1828 -->
|
|
392
|
+
# NOTE: "priority:P3" is intentionally NOT in the --label filter — GH label filters are
|
|
393
|
+
# exact-match and can't OR it with bare "P3", so the P3 test moves into the jq predicate
|
|
394
|
+
# below (schema-tolerant, mirrors phase-1-resolve.md exactly). <!-- Added: forge#2232 -->
|
|
395
|
+
# Safety-exclusion alternation is word-boundary anchored (not bare substrings)
|
|
396
|
+
# and the body scan strips the review-finding template's attribution
|
|
397
|
+
# boilerplate (**Confidence**/**Severity**/**Review comment** — see forge#2477
|
|
398
|
+
# note below for why **Source**/**Agent** are deliberately excluded from this
|
|
399
|
+
# list) before matching — identical fix mirrored across all three sweep
|
|
400
|
+
# sites so `authority_source` and a Security-agent-attributed finding no
|
|
401
|
+
# longer false-positive, while a genuine auth/billing/security/anti-bot
|
|
402
|
+
# finding still excludes. <!-- forge#2423 -->
|
|
403
|
+
# Each stripped alternative is anchored to the field's real generator-output
|
|
404
|
+
# shape (enum for Confidence/Severity, URL for Review comment) rather than a
|
|
405
|
+
# bare label-prefix + `.*$` — matching on label shape alone lets
|
|
406
|
+
# attacker-controlled body text on one of these lines get stripped along with
|
|
407
|
+
# the label, smuggling banned keywords past the scan below. Source/Agent are
|
|
408
|
+
# deliberately NOT stripped: both hold genuinely free-text generator output (a
|
|
409
|
+
# PR title; an agent's self-description) with no fixed vocabulary, so no shape
|
|
410
|
+
# bound can distinguish legitimate attribution from attacker-authored payload
|
|
411
|
+
# placed in the same position — a length-bounded free-text alternative for
|
|
412
|
+
# either field re-opens the exact smuggling gap this fix closes (an attacker
|
|
413
|
+
# need only prefix their payload with a fake "PR #N — " or agent name to
|
|
414
|
+
# satisfy the bound). Leaving them unstripped trades a narrow, already-known
|
|
415
|
+
# false-positive (forge#2423's Agent-line case; a P3 finding whose Source/Agent
|
|
416
|
+
# text happens to mention a domain keyword is not auto-batched) for closing a
|
|
417
|
+
# real bypass — the safe direction for a security-relevant exclusion.
|
|
418
|
+
# <!-- forge#2477 -->
|
|
392
419
|
UNBATCHED_P3=$(gh issue list {GH_FLAG} \
|
|
393
420
|
--state open \
|
|
394
|
-
--label "review-finding
|
|
421
|
+
--label "review-finding" \
|
|
395
422
|
--limit 500 \
|
|
396
423
|
--json number,title,body,labels,createdAt \
|
|
397
|
-
--jq '.[] | select(
|
|
398
|
-
| select((.
|
|
399
|
-
| select(.
|
|
424
|
+
--jq '.[] | select([.labels[].name] | any(test("^(priority:)?P3$")))
|
|
425
|
+
| select(([.labels[].name] | any(. == "batch")) | not)
|
|
426
|
+
| select((.title | test("\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i")) | not)
|
|
427
|
+
| (.body | gsub("(?m)^\\*\\*(?:Confidence\\*\\*: (?:CONFIRMED|LIKELY|POSSIBLE)|Severity\\*\\*: (?:CRITICAL|HIGH|MEDIUM|LOW|INFO)|Review comment\\*\\*: https?://\\S+)$"; "")) as $stripped_body
|
|
428
|
+
| select($stripped_body | test("## Problem[\\s\\S]{0,500}\\b(security|billing|anti-bot|auth|authentication|authorization|authn|authz)\\b"; "i") | not)
|
|
400
429
|
| select(([.labels[].name] | any(. == "security" or . == "billing" or . == "anti-bot" or . == "auth")) | not)')
|
|
401
430
|
|
|
402
431
|
echo "Unbatched batchable P3 findings: $(echo "$UNBATCHED_P3" | jq -s 'length')"
|
package/commands/issue.md
CHANGED
|
@@ -31,6 +31,7 @@ You create GitHub issues with the exact structure the `/work-on` pipeline expect
|
|
|
31
31
|
| `--body-file <path>` | Supplies the final issue body by reading it from a file (programmatic mode only). Mutually exclusive with `--body`. |
|
|
32
32
|
| `--label "..."` | Adds one label. Repeatable (`--label bug --label P2`) — labels accumulate. Programmatic mode only. |
|
|
33
33
|
| `--milestone "..."` | Supplies the milestone title directly, skipping the Phase 2E milestone lookup. Programmatic mode only, optional. |
|
|
34
|
+
| `--exclude "N,N,..."` | Comma-separated issue numbers to exclude from the Phase 2D dedup candidate set. Programmatic mode only, optional. Forwarded verbatim to `scripts/issue-dedup.sh --exclude`. NOT a `--force` equivalent — narrows the candidate set, does not disable the gate. See Phase 2D. <!-- Added: forge#2432 --> |
|
|
34
35
|
|
|
35
36
|
```bash
|
|
36
37
|
DRY_RUN=false
|
|
@@ -39,6 +40,7 @@ PROGRAMMATIC_BODY=""
|
|
|
39
40
|
PROGRAMMATIC_BODY_FILE=""
|
|
40
41
|
PROGRAMMATIC_LABELS=()
|
|
41
42
|
PROGRAMMATIC_MILESTONE=""
|
|
43
|
+
PROGRAMMATIC_EXCLUDE=""
|
|
42
44
|
|
|
43
45
|
# Positional/flag parsing. $ARGUMENTS is a single opaque string (the invoking
|
|
44
46
|
# agent's raw args, e.g. from `Skill(skill="issue", args="--title \"fix: ...\" ...")`)
|
|
@@ -107,7 +109,7 @@ rm -f "$ARGS_TMP"
|
|
|
107
109
|
LOOKS_LIKE_FLAG_MODE=false
|
|
108
110
|
for arg in "${ARGS[@]}"; do
|
|
109
111
|
case "$arg" in
|
|
110
|
-
--dry-run|--title|--body|--body-file|--label|--milestone)
|
|
112
|
+
--dry-run|--title|--body|--body-file|--label|--milestone|--exclude)
|
|
111
113
|
LOOKS_LIKE_FLAG_MODE=true
|
|
112
114
|
break
|
|
113
115
|
;;
|
|
@@ -119,7 +121,7 @@ while [ "$LOOKS_LIKE_FLAG_MODE" = "true" ] && [ $i -lt ${#ARGS[@]} ]; do
|
|
|
119
121
|
arg="${ARGS[$i]}"
|
|
120
122
|
case "$arg" in
|
|
121
123
|
--dry-run) DRY_RUN=true ;;
|
|
122
|
-
--title|--body|--body-file|--label|--milestone)
|
|
124
|
+
--title|--body|--body-file|--label|--milestone|--exclude)
|
|
123
125
|
# Each of these flags requires a value token immediately after it. A flag
|
|
124
126
|
# with no following token (e.g. --title as the last argument) is a usage
|
|
125
127
|
# error, not an empty value — fail loudly instead of silently proceeding
|
|
@@ -136,6 +138,7 @@ while [ "$LOOKS_LIKE_FLAG_MODE" = "true" ] && [ $i -lt ${#ARGS[@]} ]; do
|
|
|
136
138
|
--body-file) PROGRAMMATIC_BODY_FILE="${ARGS[$i]}" ;;
|
|
137
139
|
--label) PROGRAMMATIC_LABELS+=("${ARGS[$i]}") ;;
|
|
138
140
|
--milestone) PROGRAMMATIC_MILESTONE="${ARGS[$i]}" ;;
|
|
141
|
+
--exclude) PROGRAMMATIC_EXCLUDE="${ARGS[$i]}" ;;
|
|
139
142
|
esac
|
|
140
143
|
;;
|
|
141
144
|
--*)
|
|
@@ -177,7 +180,7 @@ fi
|
|
|
177
180
|
For callers that have already composed a title, body, labels, and (optionally) a milestone — audit tools, `/work-on` decomposition, pipeline-health proposals, review-finding creation, and similar automation — pass `--title` to activate programmatic mode. This bypasses Phase 1 (free-text parsing) and Phase 3 (LLM drafting) entirely; Phase 2D (dedup) and body validation still run.
|
|
178
181
|
|
|
179
182
|
**Required**: `--title "TEXT"` and exactly one of `--body "TEXT"` / `--body-file <path>`.
|
|
180
|
-
**Optional**: `--label "NAME"` (repeatable, zero or more), `--milestone "TITLE"`, `--dry-run
|
|
183
|
+
**Optional**: `--label "NAME"` (repeatable, zero or more), `--milestone "TITLE"`, `--dry-run`, `--exclude "N,N,..."` (see Phase 2D — narrows the dedup candidate set, not a `--force` equivalent).
|
|
181
184
|
|
|
182
185
|
If `--title` is present but neither `--body` nor `--body-file` is supplied, or both are supplied, this is a usage error: print `ERROR: programmatic mode requires exactly one of --body or --body-file` and STOP — do not fall through to Phase 2D or Phase 4B.
|
|
183
186
|
|
|
@@ -340,7 +343,12 @@ Run the deterministic dedup script first (authoritative check), then fall back t
|
|
|
340
343
|
|
|
341
344
|
```bash
|
|
342
345
|
# Authoritative deterministic check — uses token overlap algorithm (see scripts/issue-dedup.sh)
|
|
343
|
-
|
|
346
|
+
# In programmatic mode, forward $PROGRAMMATIC_EXCLUDE (if set) as --exclude — see the
|
|
347
|
+
# "Exclusion mechanism" note below. In interactive mode $PROGRAMMATIC_EXCLUDE is always
|
|
348
|
+
# empty, so this is a no-op for that path.
|
|
349
|
+
DEDUP_EXCLUDE_ARGS=()
|
|
350
|
+
[ -n "$PROGRAMMATIC_EXCLUDE" ] && DEDUP_EXCLUDE_ARGS=(--exclude "$PROGRAMMATIC_EXCLUDE")
|
|
351
|
+
DEDUP_RESULT=$(scripts/issue-dedup.sh "{PROPOSED_TITLE}" {GH_FLAG} "${DEDUP_EXCLUDE_ARGS[@]}" 2>&1)
|
|
344
352
|
DEDUP_EXIT=$?
|
|
345
353
|
|
|
346
354
|
if [ "$DEDUP_EXIT" -eq 1 ]; then
|
|
@@ -369,6 +377,8 @@ If a duplicate exists:
|
|
|
369
377
|
- **Closed duplicate found (regression)**: Create the issue but reference the prior issue in the body: "Regression of #{N}."
|
|
370
378
|
- **User wants to override**: Pass `--force` to the dedup script — this is an explicit human override path. Agents MUST NOT pass `--force` without user authorization. <!-- Added: forge#1335 -->
|
|
371
379
|
|
|
380
|
+
**Exclusion mechanism (`--exclude`, programmatic mode only)**: A caller that already knows a set of open issue numbers this new issue is a deliberate supersede of — not an accidental duplicate — can pass `--exclude "N,N,..."` to `/issue` (forwarded to `scripts/issue-dedup.sh --exclude`). The canonical example is `phase-1-resolve.md`'s P3 review-finding batching: a batch issue restates its own member findings' subject matter by construction, so without exclusion it always collides with them under the token-overlap algorithm. `--exclude` removes those specific issue numbers from Phase 2D's candidate set BEFORE matching runs — it is **NOT a `--force` equivalent**: it narrows the candidate set, it does not disable the check, and it requires no human authorization (unlike `--force`, which agents MUST NOT pass without one). Any OTHER open issue — one not in the exclusion list — is still matched normally, so a title that happens to duplicate an unrelated existing issue still trips the STOP. <!-- Added: forge#2432 -->
|
|
381
|
+
|
|
372
382
|
### 2E: Check for milestone context
|
|
373
383
|
|
|
374
384
|
```bash
|
|
@@ -54,9 +54,51 @@ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
|
|
|
54
54
|
# renders full tables once at the end regardless of this value.
|
|
55
55
|
NARRATION_MODE=$(yq '.pipeline.narration // "terse"' "$CONFIG_FILE" 2>/dev/null || echo "terse")
|
|
56
56
|
[ "$NARRATION_MODE" = "null" ] && NARRATION_MODE="terse"
|
|
57
|
+
|
|
58
|
+
# Cascade admission policy (forge#2234) — CLI flags take precedence over forge.yaml, which
|
|
59
|
+
# takes precedence over the "balanced" default. Mirrors the --model <name> precedence
|
|
60
|
+
# pattern below. See docs/CONFIG.md → `orchestration` for the full field reference and
|
|
61
|
+
# `phase-4-execution.md` / `phase-1-resolve.md` for where each resolved value is consumed.
|
|
62
|
+
# Extraction regex is an explicit character allowlist (`[A-Za-z0-9_-]+`), NOT a bare
|
|
63
|
+
# `\S+` — `\S+` would accept `"`, `` ` ``, `$`, `(`, `)`, which a downstream yq fallback
|
|
64
|
+
# embedding (`.orchestration.cascade.policy // "${CLI_CASCADE_POLICY:-balanced}"`) could
|
|
65
|
+
# not safely absorb: an embedded `"` breaks out of the yq string literal, letting the
|
|
66
|
+
# remainder be parsed as yq expression syntax (yq-expression injection — not shell/command
|
|
67
|
+
# injection, since `awk` here only extracts text, never executes it). The allowlist also
|
|
68
|
+
# matches the format every valid value actually takes: policy names are
|
|
69
|
+
# `all`/`balanced`/`conservative`; max-generation is a positive integer or `unlimited`;
|
|
70
|
+
# token-budget is a positive integer or `unlimited` — none of these ever contain anything
|
|
71
|
+
# outside `[A-Za-z0-9_-]`. (forge#2301)
|
|
72
|
+
echo "{ARGUMENTS}" | grep -qE -- '--policy[[:space:]]+[A-Za-z0-9_-]+' && \
|
|
73
|
+
CLI_CASCADE_POLICY=$(echo "{ARGUMENTS}" | grep -oE -- '--policy[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
|
|
74
|
+
echo "{ARGUMENTS}" | grep -qE -- '--max-generation[[:space:]]+[A-Za-z0-9_-]+' && \
|
|
75
|
+
CLI_MAX_GENERATION=$(echo "{ARGUMENTS}" | grep -oE -- '--max-generation[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
|
|
76
|
+
echo "{ARGUMENTS}" | grep -qE -- '--token-budget[[:space:]]+[A-Za-z0-9_-]+' && \
|
|
77
|
+
CLI_TOKEN_BUDGET=$(echo "{ARGUMENTS}" | grep -oE -- '--token-budget[[:space:]]+[A-Za-z0-9_-]+' | awk '{print $2}')
|
|
78
|
+
# CLI_CASCADE_POLICY / CLI_MAX_GENERATION / CLI_TOKEN_BUDGET, when set, override the
|
|
79
|
+
# corresponding forge.yaml → orchestration.cascade.* key for this invocation only — pass
|
|
80
|
+
# them through as the yq `//` fallback value at each Step 4A.pre / Phase 1 resolution site
|
|
81
|
+
# (`... // "${CLI_CASCADE_POLICY:-balanced}"`, etc.) instead of the bare literal default.
|
|
82
|
+
# Even with the allowlist above, the final resolved value is STILL validated against the
|
|
83
|
+
# same regex (`^[1-9][0-9]*$|^unlimited$` for the two numeric levers,
|
|
84
|
+
# `^(all|balanced|conservative)$` for policy) at its consuming site in
|
|
85
|
+
# phase-4-execution.md / phase-1-resolve.md before use — this allowlist is defense in
|
|
86
|
+
# depth, not a replacement for that validation.
|
|
57
87
|
```
|
|
58
88
|
|
|
59
|
-
All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{PROJECT_NAME}`, `{STAGING_BRANCH}`, `{DEFAULT_BRANCH}`, and `{
|
|
89
|
+
All `{GH_REPO}`, `{GH_FLAG}`, `{REPO_PATH}`, `{PROJECT_NAME}`, `{STAGING_BRANCH}`, `{DEFAULT_BRANCH}`, `{NARRATION_MODE}`, and `{CLI_CASCADE_POLICY}`/`{CLI_MAX_GENERATION}`/`{CLI_TOKEN_BUDGET}` references below are populated from `forge.yaml` and CLI flags.
|
|
90
|
+
|
|
91
|
+
### Cascade Policy CLI Flags
|
|
92
|
+
|
|
93
|
+
| Flag | Overrides | Example |
|
|
94
|
+
|------|-----------|---------|
|
|
95
|
+
| `--policy <all\|balanced\|conservative>` | `orchestration.cascade.policy` | `/orchestrate cascade --policy all` |
|
|
96
|
+
| `--max-generation <N\|unlimited>` | `orchestration.cascade.max_generation` | `/orchestrate cascade --max-generation 3` |
|
|
97
|
+
| `--token-budget <N\|unlimited>` | `orchestration.cascade.token_budget` | `/orchestrate cascade --token-budget unlimited` |
|
|
98
|
+
|
|
99
|
+
Precedence: CLI flag > `forge.yaml → orchestration.cascade.*` > preset default (`balanced`). These flags are additive with, not a replacement for, the pre-existing `--include-deferred`/`--allow-gen2` flags documented in `phase-1-resolve.md` — those remain the fastest way to say "everything, right now" for generation *depth*, while `--max-generation`/`--policy` express finer-grained depth policy (e.g. "admit gen-2, stop at gen-3").
|
|
100
|
+
|
|
101
|
+
**Time scope is a separate axis from depth** (forge#2628): by default, `cascade`/`review-findings`/`findings` only resolves `review-finding` issues created at/after this batch's start (T0) — see `phase-1-resolve.md` "Cascade / Review-Finding Resolution" for the run-spawned-vs-backlog distinction. None of the depth flags/config above widen that time window. `--include-backlog` is the only flag that does — it opts into the whole open-backlog sweep regardless of when issues were created, and must be requested explicitly; it is never implied by adding `cascade` to an already-scoped batch.
|
|
60
102
|
|
|
61
103
|
---
|
|
62
104
|
|