forgedock 1.37.0 → 1.38.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.
Files changed (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. package/scripts/review-pr-delta-wiring.test.sh +1 -1
@@ -0,0 +1,244 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { test } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { tmpDir } from "../tests/_tmp.mjs";
6
+ import { loadWatches, validateWatch, issueWatchFromCli, runWatch, formatStatus, claimIssue, repoDir } from "./issuewatch.mjs";
7
+ import { priorityOf, poolScore, triggers, classifyTheme, classifyPool, releasePriorities, closure, decideRelease, formatDryRun, releaseTick } from "./issuewatch-release.mjs";
8
+
9
+ const NOW = Date.parse("2026-10-30T00:00:00Z");
10
+ const iss = (n, o = {}) => ({ number: n, title: `t${n}`, body: "", labels: [], milestone: null, authorAssociation: "OWNER", author: { login: "me" }, createdAt: "2026-10-29T00:00:00Z", ...o });
11
+ const lbl = (...n) => n.map((name) => ({ name }));
12
+ const THEMES = { security: ["label:security", "path:infra/ssh-guard/**"], stealth: ["path:services/worker/**/anti_detection/**", "label:scraper"], billing: ["label:billing", "scope:pricing"] };
13
+ const W = (o = {}) => validateWatch("r", { mode: "release", threshold: { min_issues: 3 }, theme_map: THEMES, ...o });
14
+
15
+ test("config: flow-map threshold, theme_map block and immediate list parse; release keys need mode release", () => {
16
+ const w = loadWatches(`issue_watch:\n watches:\n sr:\n milestone: none\n mode: release\n threshold: { min_issues: 15, min_score: 40, max_age_days: 5 }\n immediate: [label:security, priority:P0]\n max_release_issues: 40\n theme_map:\n security: [label:security, path:infra/ssh-guard/**]\n billing: [label:billing, path:shared/pricing.py]\n parallel: 6\n`).sr;
17
+ assert.deepEqual(w.threshold, { min_issues: 15, min_score: 40, max_age_days: 5 });
18
+ assert.deepEqual(w.immediate, ["label:security", "priority:P0"]);
19
+ assert.deepEqual(w.theme_map.security, ["label:security", "path:infra/ssh-guard/**"]);
20
+ assert.equal(w.max_release_issues, 40);
21
+ assert.equal(w.parallel, 6);
22
+ assert.throws(() => validateWatch("x", { threshold: { min_issues: 3 } }), /needs mode: release/);
23
+ assert.throws(() => validateWatch("x", { mode: "release" }), /threshold or immediate/);
24
+ assert.throws(() => validateWatch("x", { mode: "release", threshold: { min_issues: 0 } }), /positive/);
25
+ assert.throws(() => validateWatch("x", { mode: "release", immediate: ["bogus"] }), /immediate/);
26
+ assert.throws(() => validateWatch("x", { mode: "release", threshold: { min_issues: 1 }, theme_map: { a: ["nope"] } }), /theme_map/);
27
+ });
28
+
29
+ test("score weights P0=8 P1=4 P2=2 P3=1 and unprioritised 1", () => {
30
+ assert.equal(priorityOf(iss(1, { labels: lbl("priority:P1") })), "P1");
31
+ assert.equal(poolScore([iss(1, { labels: lbl("P0") }), iss(2, { labels: lbl("priority:P1") }), iss(3, { labels: lbl("P2") }), iss(4, { labels: lbl("P3") }), iss(5)]), 16);
32
+ });
33
+
34
+ test("each trigger type fires alone, and none fires below thresholds", () => {
35
+ const w = W({ threshold: { min_issues: 3, min_score: 10, max_age_days: 5 }, immediate: ["label:security", "priority:P0"] });
36
+ const kinds = (pool) => triggers(pool, w, NOW).map((t) => t.kind);
37
+ assert.deepEqual(kinds([iss(1), iss(2)]), []);
38
+ assert.deepEqual(kinds([iss(1), iss(2), iss(3)]), ["min_issues"]);
39
+ assert.deepEqual(kinds([iss(1, { labels: lbl("P0") }), iss(2, { labels: lbl("P2") })]).includes("min_score"), true);
40
+ assert.deepEqual(kinds([iss(1, { createdAt: "2026-10-20T00:00:00Z" })]), ["max_age_days"]);
41
+ assert.deepEqual(kinds([iss(1, { labels: lbl("security") })]), ["immediate"]);
42
+ assert.deepEqual(kinds([iss(1, { labels: lbl("P0") })]).includes("immediate"), true);
43
+ assert.deepEqual(triggers([], w, NOW), []);
44
+ });
45
+
46
+ test("themes classify from labels, title scope and named paths; first rule wins; scope equal to a theme name works", () => {
47
+ assert.equal(classifyTheme(iss(1, { labels: lbl("security") }), THEMES).theme, "security");
48
+ assert.equal(classifyTheme(iss(2, { body: "Touches `infra/ssh-guard/keys.py`." }), THEMES).theme, "security");
49
+ assert.equal(classifyTheme(iss(3, { body: "see services/worker/x/anti_detection/ua.py" }), THEMES).theme, "stealth");
50
+ assert.equal(classifyTheme(iss(4, { title: "fix(pricing): rounding" }), THEMES).theme, "billing");
51
+ assert.equal(classifyTheme(iss(5, { title: "feat(stealth): x" }), THEMES).via, "scope:stealth");
52
+ assert.equal(classifyTheme(iss(6, { body: "docs/readme.md" }), THEMES), null);
53
+ });
54
+
55
+ test("LLM fallback only for unclassified issues, counted, and cached", async () => {
56
+ const calls = [];
57
+ const classify = async (rest) => { calls.push(rest.map((i) => i.number)); return { 7: "billing", 8: "nonsense" }; };
58
+ const pool = [iss(1, { labels: lbl("security") }), iss(7), iss(8)];
59
+ const r = await classifyPool(pool, W(), { classify });
60
+ assert.deepEqual(calls, [[7, 8]]);
61
+ assert.equal(r.llm, 2);
62
+ assert.deepEqual([r.themes[1].theme, r.themes[7].theme, r.themes[8].theme], ["security", "billing", "general"]);
63
+ const r2 = await classifyPool([pool[0]], W(), { classify });
64
+ assert.equal(r2.llm, 0); assert.equal(calls.length, 1); // all classified by rules: no call
65
+ const none = await classifyPool(pool, W(), {}); // no classifier: general, no count
66
+ assert.equal(none.llm, 0); assert.equal(none.themes[7].theme, "general");
67
+ });
68
+
69
+ test("releasePriorities reads an inline or bulleted NORTHSTAR list", () => {
70
+ assert.deepEqual(releasePriorities("# X\nRelease priorities: Security, billing\n"), ["security", "billing"]);
71
+ assert.deepEqual(releasePriorities("Release priorities:\n- security\n- `stealth`\n\nother"), ["security", "stealth"]);
72
+ assert.deepEqual(releasePriorities("nothing"), []);
73
+ });
74
+
75
+ const pooled = (...o) => { const pool = o.map((x, k) => iss(k + 1, x)); return { pool, openByNum: new Map(pool.map((i) => [i.number, i])) }; };
76
+ const themesOf = (pool, w) => Object.fromEntries(pool.map((i) => [i.number, classifyTheme(i, w.theme_map) ?? { theme: "general", via: "unclassified" }]));
77
+
78
+ test("decide picks the top-scoring theme; NORTHSTAR order breaks ties; nothing is cut below threshold", () => {
79
+ const w = W();
80
+ const { pool, openByNum } = pooled({ labels: lbl("billing") }, { labels: lbl("billing") }, { labels: lbl("security") });
81
+ const d = decideRelease({ pool, openByNum, themes: themesOf(pool, w), watch: w, now: NOW });
82
+ assert.equal(d.theme, "billing"); assert.deepEqual(d.release.set.map((i) => i.number), [1, 2]);
83
+ assert.match(d.below[0], /security pool 1 < threshold/);
84
+ const tie = pooled({ labels: lbl("billing") }, { labels: lbl("security") }, { labels: lbl("security") }, { labels: lbl("billing") });
85
+ assert.equal(decideRelease({ pool: tie.pool, openByNum: tie.openByNum, themes: themesOf(tie.pool, w), watch: w, now: NOW, priorities: ["security"] }).theme, "security");
86
+ const few = pooled({ labels: lbl("billing") });
87
+ const d2 = decideRelease({ pool: few.pool, openByNum: few.openByNum, themes: themesOf(few.pool, w), watch: w, now: NOW });
88
+ assert.equal(d2.release, null);
89
+ });
90
+
91
+ test("immediate security issue below threshold cuts a security release alone, without other themes", () => {
92
+ const w = W({ threshold: { min_issues: 5 }, immediate: ["label:security"] });
93
+ const { pool, openByNum } = pooled({ labels: lbl("billing") }, { labels: lbl("security") }, { labels: lbl("billing", "P0") }, { labels: lbl("security") });
94
+ const d = decideRelease({ pool, openByNum, themes: themesOf(pool, w), watch: w, now: NOW });
95
+ assert.equal(d.forced, true); assert.equal(d.theme, "security");
96
+ assert.deepEqual(d.release.set.map((i) => i.number).sort(), [2, 4]);
97
+ assert.ok(d.release.excluded.some((e) => e.number === 1 && /theme billing/.test(e.reason)));
98
+ });
99
+
100
+ test("release set pulls the Depends on closure (cross-theme, marked), skips closed deps, and respects the cap", () => {
101
+ const w = W({ max_release_issues: 3 });
102
+ const a = iss(1, { labels: lbl("security"), body: "**Depends on** #9\n" });
103
+ const dep = iss(9, { labels: lbl("billing"), body: "Depends on #10" });
104
+ const deep = iss(10, { labels: lbl("billing") });
105
+ const b = iss(2, { labels: lbl("security"), body: "Depends on #99" }); // 99 is closed/unknown
106
+ const c = iss(3, { labels: lbl("security") });
107
+ const pool = [a, b, c], all = [a, b, c, dep, deep];
108
+ const openByNum = new Map(all.map((i) => [i.number, i]));
109
+ assert.deepEqual(closure(a, openByNum).map((i) => i.number).sort((x, y) => x - y), [1, 9, 10]);
110
+ const d = decideRelease({ pool, openByNum, themes: themesOf(pool, w), watch: w, now: NOW });
111
+ assert.deepEqual(d.release.set.map((i) => i.number).sort((x, y) => x - y), [1, 9, 10]); // cap 3 filled by #1's closure
112
+ assert.deepEqual(d.release.pulled.map((p) => [p.number, p.theme, p.for]), [[9, "billing", 1], [10, "billing", 1]]);
113
+ assert.deepEqual(d.release.excluded.map((e) => e.number).sort(), [2, 3]); // roll over, still pooled
114
+ const wide = decideRelease({ pool, openByNum, themes: themesOf(pool, w), watch: W({ max_release_issues: 10 }), now: NOW });
115
+ assert.equal(wide.release.set.length, 5);
116
+ });
117
+
118
+ test("dry run prints would cut, theme scores and per-issue themes", () => {
119
+ const w = W();
120
+ const { pool, openByNum } = pooled({ labels: lbl("billing") }, { labels: lbl("billing") }, { labels: lbl("billing") }, { labels: lbl("security") });
121
+ const themes = themesOf(pool, w);
122
+ const out = formatDryRun(decideRelease({ pool, openByNum, themes, watch: w, now: NOW }), pool, themes);
123
+ assert.match(out, /would cut: billing \(3 issues, score \d+; security pool 1 < threshold\)/);
124
+ assert.match(out, /theme scores: billing=\d+ \(3\), security=\d+ \(1\)/);
125
+ assert.match(out, /#4 security \[label:security\] \(stays pooled\)/);
126
+ });
127
+
128
+ /** Fake io for releaseTick: gh records writes; batch start/reported controllable. */
129
+ function world(issues, { closed = new Set(), prs = [] } = {}) {
130
+ const io = { calls: [], batches: [], reported: new Set(), closed, now: () => NOW, classify: undefined,
131
+ gh: async (a) => {
132
+ io.calls.push(a);
133
+ if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ state: closed.has(+a[2]) ? "CLOSED" : "OPEN" });
134
+ if (a[0] === "pr" && a[1] === "list") return JSON.stringify(prs);
135
+ if (a[0] === "pr" && a[1] === "create") return "https://github.com/o/r/pull/77\n";
136
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(issues);
137
+ return "";
138
+ },
139
+ batch: { start: async (nums, k, lane) => { io.batches.push([nums, k, lane]); return `b${io.batches.length}`; }, reported: (id) => io.reported.has(id) } };
140
+ return io;
141
+ }
142
+ const step = (io, w, state, o = {}) => {
143
+ const claims = o.claims ?? new Set();
144
+ return releaseTick({ watch: w, repo: "o/r", io, state, defaults: ["main"], listOpen: async () => JSON.parse(await io.gh(["issue", "list"])),
145
+ eligible: (i) => !state.claimed.includes(i.number) && !claims.has(i.number), claim: (n) => { if (claims.has(n)) return false; claims.add(n); return true; },
146
+ laneFor: () => "staging", dagOrder: (x) => x, policy: { path: "NORTHSTAR.md", text: "" } });
147
+ };
148
+ const fresh = () => ({ claimed: [], inflight: [], merged: [], verified: [], failed: [], lanes: {}, started: null });
149
+
150
+ test("claims nothing below the threshold; at the threshold posts a plan, tags with a label, starts one batch, then opens the deploy PR once and never merges", async () => {
151
+ const issues = [iss(1, { labels: lbl("billing") }), iss(2, { labels: lbl("billing") })];
152
+ const io = world(issues), w = W(), st = fresh();
153
+ await step(io, w, st);
154
+ assert.equal(io.batches.length, 0); assert.deepEqual(st.claimed, []); assert.deepEqual(st.pool, [1, 2]);
155
+ issues.push(iss(3, { labels: lbl("billing") }), iss(4, { labels: lbl("security") }));
156
+ await step(io, w, st);
157
+ assert.deepEqual(io.batches, [[[1, 2, 3], 4, "staging"]]);
158
+ assert.deepEqual(st.claimed, [1, 2, 3]); assert.equal(st.release.tag, "billing-2026-10-30");
159
+ const comment = io.calls.find((a) => a[1] === "comment");
160
+ assert.match(comment[comment.length - 1], /<!-- FORGE:RELEASE_PLAN \{/); assert.equal(comment[2], "1");
161
+ assert.ok(io.calls.some((a) => a[0] === "label" && a[1] === "create" && a.includes("release:billing-2026-10-30")));
162
+ assert.equal(io.calls.filter((a) => a[1] === "edit" && a.includes("--add-label")).length, 3);
163
+ await step(io, w, st); // batch not reported: one batch per release, nothing new
164
+ assert.equal(io.batches.length, 1); assert.equal(io.calls.filter((a) => a[0] === "pr").length, 0);
165
+ io.reported.add("b1"); io.closed.add(1);
166
+ await step(io, w, st);
167
+ const creates = io.calls.filter((a) => a[0] === "pr" && a[1] === "create");
168
+ assert.equal(creates.length, 1); assert.deepEqual([creates[0][creates[0].indexOf("--base") + 1], creates[0][creates[0].indexOf("--head") + 1]], ["main", "staging"]);
169
+ assert.equal(io.calls.some((a) => a[0] === "pr" && a[1] === "merge"), false);
170
+ assert.deepEqual([st.merged, st.failed.sort()], [[1], [2, 3]]); assert.equal(st.releases[0].deployPr, 77);
171
+ });
172
+
173
+ test("rollover: issues left out stay pooled with their age and go in the next release; a new batch starts after the report", async () => {
174
+ const issues = [1, 2, 3, 4].map((n) => iss(n, { labels: lbl("billing"), createdAt: `2026-10-0${n}T00:00:00Z` }));
175
+ const io = world(issues), w = W({ max_release_issues: 3 }), st = fresh();
176
+ await step(io, w, st);
177
+ assert.deepEqual(io.batches[0][0], [1, 2, 3]);
178
+ io.reported.add("b1"); issues.push(iss(5, { labels: lbl("billing") }), iss(6, { labels: lbl("billing") }));
179
+ await step(io, w, st); // settles the first release and cuts the next in the same pass
180
+ assert.deepEqual(io.batches[1][0], [4, 5, 6]); assert.equal(st.releases.length, 1);
181
+ });
182
+
183
+ test("a merge-protected default: a milestone target tags with a release/<tag> milestone", async () => {
184
+ const io = world([1, 2, 3].map((n) => iss(n, { labels: lbl("security") }))), w = W({ milestone: "v1" }), st = fresh();
185
+ await step(io, w, st);
186
+ assert.ok(io.calls.some((a) => a[0] === "api" && a.includes("title=release/security-2026-10-30")));
187
+ assert.equal(io.calls.filter((a) => a[1] === "edit" && a.includes("--milestone")).length, 3);
188
+ });
189
+
190
+ test("release #4820 CONC-1: an existing release milestone is reused, not a throw that strands the claims", async () => {
191
+ const io = world([1, 2, 3].map((n) => iss(n, { labels: lbl("security") }))), w = W({ milestone: "v1" }), st = fresh();
192
+ const gh = io.gh;
193
+ io.gh = async (a) => { if (a[0] === "api" && String(a[1]).endsWith("/milestones")) { io.calls.push(a); throw Object.assign(new Error("gh: Validation Failed (HTTP 422)"), { stderr: "already_exists" }); } return gh(a); };
194
+ await step(io, w, st);
195
+ assert.deepEqual(io.batches[0][0], [1, 2, 3]);
196
+ assert.deepEqual(st.claimed, [1, 2, 3]);
197
+ assert.equal(io.calls.filter((a) => a[1] === "edit" && a.includes("--milestone")).length, 3);
198
+ io.gh = async (a) => { if (a[0] === "api") throw new Error("HTTP 500"); return gh(a); };
199
+ await assert.rejects(step(io, W({ milestone: "v1" }), fresh()), /HTTP 500/, "any other failure still throws");
200
+ });
201
+
202
+ test("release #4820 SPEC-2: a Depends-on prerequisite outside the eligible pool is never claimed or launched", async () => {
203
+ const outsider = iss(9, { labels: lbl("billing"), authorAssociation: "NONE" });
204
+ const issues = [iss(1, { labels: lbl("billing"), body: "Depends on #9" }), iss(2, { labels: lbl("billing") }), iss(3, { labels: lbl("billing") }), outsider];
205
+ const io = world(issues), w = W(), st = fresh(), claimed = [];
206
+ await releaseTick({ watch: w, repo: "o/r", io, state: st, defaults: ["main"], listOpen: async () => JSON.parse(await io.gh(["issue", "list"])),
207
+ eligible: (i) => i.authorAssociation !== "NONE" && !st.claimed.includes(i.number), claim: (n) => { claimed.push(n); return true; },
208
+ laneFor: () => "staging", dagOrder: (x) => x, policy: { path: "NORTHSTAR.md", text: "" } });
209
+ assert.ok(!claimed.includes(9), "the outsider prerequisite is not claimed");
210
+ assert.ok(!io.batches.flatMap((b) => b[0]).includes(9), "nor launched");
211
+ });
212
+
213
+ test("claimed elsewhere is not double-claimed; an existing deploy PR is reused; dry run writes nothing", async () => {
214
+ const io = world([1, 2, 3, 4].map((n) => iss(n, { labels: lbl("billing") })), { prs: [{ number: 5 }] }), w = W(), st = fresh(), claims = new Set([2]);
215
+ await step(io, w, st, { claims });
216
+ assert.deepEqual(io.batches[0][0], [1, 3, 4]);
217
+ io.reported.add("b1"); await step(io, w, st, { claims });
218
+ assert.equal(io.calls.some((a) => a[0] === "pr" && a[1] === "create"), false); assert.equal(st.releases[0].deployPr, 5);
219
+ const io2 = world([1, 2, 3].map((n) => iss(n, { labels: lbl("billing") }))), st2 = fresh();
220
+ const r = await releaseTick({ watch: w, repo: "o/r", io: io2, state: st2, defaults: ["main"], listOpen: async () => JSON.parse(await io2.gh(["issue", "list"])), eligible: () => true, claim: () => { throw new Error("no"); }, laneFor: () => "staging", dagOrder: (x) => x, dryRun: true });
221
+ assert.equal(r.decision.theme, "billing"); assert.deepEqual(io2.calls.filter((a) => a[1] !== "list"), []); assert.equal(io2.batches.length, 0);
222
+ });
223
+
224
+ test("max_issues and until stop new releases (done once nothing is in flight)", async () => {
225
+ const io = world([1, 2, 3].map((n) => iss(n, { labels: lbl("billing") }))), w = W({ until: "2026-10-29T00:00:00Z" }), st = fresh();
226
+ await step(io, w, st);
227
+ assert.equal(st.phase, "done"); assert.equal(io.batches.length, 0);
228
+ });
229
+
230
+ test("CLI: --dry-run prints the cut without writing; refuses a non-release watch; status shows the pool", async () => {
231
+ const dir = tmpDir("iwr-"), cwd = tmpDir("iwr-cwd-");
232
+ const { writeFileSync } = await import("node:fs");
233
+ writeFileSync(`${cwd}/forge.yaml`, `issue_watch:\n watches:\n sr:\n mode: release\n threshold: { min_issues: 2 }\n theme_map:\n billing: [label:billing]\n plain:\n milestone: none\n`);
234
+ const issues = [1, 2].map((n) => iss(n, { labels: lbl("billing") }));
235
+ const io = world(issues); io.env = { FORGEDOCK_WATCH_DIR: dir }; io.sleep = async () => {};
236
+ const orig = io.gh; io.gh = async (a) => (a[0] === "repo" ? (a.includes("--jq") ? "main\n" : JSON.stringify({ nameWithOwner: "o/r" })) : orig(a));
237
+ const out = [];
238
+ const r = await issueWatchFromCli(["run", "--name", "sr", "--dry-run"], { cwd, env: io.env, io, print: (l) => out.push(l) });
239
+ assert.match(out.join("\n"), /would cut: billing \(2 issues/);
240
+ assert.equal(io.batches.length, 0); assert.ok(r.dryRun);
241
+ await assert.rejects(issueWatchFromCli(["run", "--name", "plain", "--dry-run"], { cwd, env: io.env, io, print() {} }), /mode: release/);
242
+ const w = loadWatches(`issue_watch:\n watches:\n sr:\n mode: release\n threshold: { min_issues: 2 }\n`).sr;
243
+ assert.match(formatStatus({ name: "sr", repo: "o/r", phase: "running", claimed: [1], inflight: [1], merged: [], verified: [], failed: [], pool: [2, 3], llm: 2, releases: [], release: { theme: "billing", issues: [1], batch: "b1" } }, w), /pool: 2.*active: billing.*llm-classified: 2/);
244
+ });