forgedock 1.45.0 → 1.47.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/engine/outcome-codes.mjs +2 -1
- package/bin/engine-cli.mjs +22 -8
- package/bin/lean/conformance.e2e.test.mjs +20 -0
- package/bin/lean/conformance.mjs +99 -9
- package/bin/lean/conformance.test.mjs +121 -4
- package/bin/lean/context.mjs +108 -6
- package/bin/lean/context.test.mjs +84 -2
- package/bin/lean/coupled.mjs +38 -0
- package/bin/lean/coupled.test.mjs +57 -0
- package/bin/lean/decision-engine.mjs +17 -2
- package/bin/lean/decision-engine.test.mjs +21 -0
- package/bin/lean/drift.mjs +3 -2
- package/bin/lean/drift.test.mjs +23 -1
- package/bin/lean/effects.mjs +3 -2
- package/bin/lean/execution.mjs +41 -8
- package/bin/lean/execution.test.mjs +72 -2
- package/bin/lean/integration.mjs +2 -2
- package/bin/lean/integration.test.mjs +22 -1
- package/bin/lean/issuewatch-decisions.mjs +175 -0
- package/bin/lean/issuewatch-decisions.test.mjs +353 -0
- package/bin/lean/issuewatch-release.test.mjs +3 -1
- package/bin/lean/issuewatch.mjs +67 -10
- package/bin/lean/issuewatch.test.mjs +18 -0
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lane.test.mjs +28 -1
- package/bin/lean/lean.mjs +64 -17
- package/bin/lean/lean.test.mjs +95 -0
- package/bin/lean/limits.mjs +6 -2
- package/bin/lean/limits.test.mjs +86 -0
- package/bin/lean/milestonesync.mjs +2 -2
- package/bin/lean/milestonesync.test.mjs +22 -0
- package/bin/lean/orchestrate.mjs +79 -9
- package/bin/lean/probe.mjs +4 -2
- package/bin/lean/probe.test.mjs +4 -0
- package/bin/lean/prompts.mjs +18 -11
- package/bin/lean/rotator.mjs +96 -0
- package/bin/lean/rotator.test.mjs +143 -0
- package/bin/lean/routecheck.mjs +48 -0
- package/bin/lean/routecheck.test.mjs +69 -0
- package/bin/lean/salvage-resume.mjs +26 -0
- package/bin/lean/salvage-resume.test.mjs +51 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
- package/bin/lean/sandbox-remote.mjs +4 -2
- package/bin/lean/verify.mjs +2 -1
- package/bin/review-staging.mjs +20 -3
- package/commands/deploy-info.md +4 -4
- package/commands/issue.md +6 -1
- package/commands/review-pr-agents/frontend.md +1 -0
- package/commands/review-pr-agents/infra.md +2 -0
- package/commands/review-pr-staging.md +2 -0
- package/commands/work-on/build/context.md +7 -1
- package/commands/work-on/investigate.md +15 -3
- package/docs/CONFIG.md +22 -3
- package/docs/DECISIONS.md +18 -7
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/RELEASE-NOTES.md +1 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +125 -28
- package/scripts/file-deferred-followups.test.sh +49 -0
- package/scripts/issue-create.sh +7 -0
- package/scripts/issue-create.test.sh +13 -0
- package/scripts/issue-dedup.sh +5 -4
- package/scripts/issue-dedup.test.sh +15 -0
- package/scripts/owner-route-parity.test.sh +32 -0
- package/scripts/prior-attempts.mjs +72 -0
- package/scripts/prior-attempts.test.mjs +52 -0
- package/scripts/review-finalize.sh +35 -4
- package/scripts/review-finalize.test.sh +10 -0
- package/scripts/review-prepare.sh +16 -4
- package/scripts/review-prepare.test.sh +8 -0
- package/scripts/review-roster.sh +3 -1
- package/scripts/review-roster.test.sh +30 -2
- package/scripts/review-staging-pre.sh +8 -6
- package/scripts/review-staging-pre.test.sh +11 -1
|
@@ -0,0 +1,353 @@
|
|
|
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 { decisionStep, decisionsActive, decisionContext, decisionCounts, escalationOf, terminalCodeOf, claimsUsed } from "./issuewatch-decisions.mjs";
|
|
6
|
+
import { validateWatch, matchesSelectors, formatStatus, runWatch, labelNames, isBot, TRUSTED, decisionLaunch } from "./issuewatch.mjs";
|
|
7
|
+
import { loadPolicy } from "./decision-engine.mjs";
|
|
8
|
+
import { writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
11
|
+
|
|
12
|
+
const W = (o = {}) => validateWatch("w", o);
|
|
13
|
+
const NOW = Date.parse("2026-10-30T12:00:00Z");
|
|
14
|
+
const esc = (id, text, at = "2026-10-30T13:00:00Z") => ({ url: `https://x/issues/1#issuecomment-${id}`, authorAssociation: "MEMBER", createdAt: at, body: `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${text}` });
|
|
15
|
+
const stateC = (code) => ({ url: "https://x/issues/1#issuecomment-1", authorAssociation: "MEMBER", createdAt: "2026-10-29T00:00:00Z", body: `<!-- FORGE:STATE\n${JSON.stringify({ terminalCode: code })}\n-->` });
|
|
16
|
+
const good = { choice: "use option A", clause: "NORTHSTAR 2: cut waste", rejected: "B", reversibility: "reversible", confidence: "high" };
|
|
17
|
+
const files = (m) => (p) => { const k = p.replace(/^\.\//, ""); if (k in m) return m[k]; throw new Error("ENOENT"); };
|
|
18
|
+
const ctxOf = (yaml = "decisions:\n mode: live\n", extra = {}) => decisionContext({ loadPolicy, readFile: files({ "NORTHSTAR.md": "# star", "forge.yaml": yaml, ...extra }), cwd: "." });
|
|
19
|
+
|
|
20
|
+
/** gh fake: `issues` = [{number, comments, labels?, author?, body?}] all open and needs-human. Records every write. */
|
|
21
|
+
function fakeIo(issues, { judge = () => good, bodies = {} } = {}) {
|
|
22
|
+
const writes = [], io = { writes, bodies, now: () => NOW, judge: async (q) => { io.judged.push(q.issue); return judge(q); }, judged: [] };
|
|
23
|
+
io.gh = async (a) => {
|
|
24
|
+
if (a[0] === "api") return issues.map((i) => `${i.number}\t${i.assoc ?? "OWNER"}`).join("\n");
|
|
25
|
+
if (a[1] === "list") return JSON.stringify(issues.map((i) => ({ number: i.number, title: `t${i.number}`, body: i.body ?? "", labels: [{ name: "needs-human" }, ...(i.labels ?? []).map((name) => ({ name }))], milestone: null, author: { login: "me" }, createdAt: "2026-10-01T00:00:00Z" })));
|
|
26
|
+
if (a[1] === "view" && a.includes("comments,body,labels")) { const i = issues.find((x) => x.number === +a[2]); return JSON.stringify({ comments: i.comments, body: i.body ?? "", labels: [] }); }
|
|
27
|
+
if (a[1] === "view" && a.includes("comments,labels")) { const i = issues.find((x) => x.number === +a[2]); return JSON.stringify({ comments: i.comments, labels: (i.labels ?? []).map((name) => ({ name })) }); }
|
|
28
|
+
if (a[1] === "view") return JSON.stringify({ body: bodies[a[2]] ?? "" });
|
|
29
|
+
writes.push(a);
|
|
30
|
+
return "";
|
|
31
|
+
};
|
|
32
|
+
return io;
|
|
33
|
+
}
|
|
34
|
+
const st = (watch, extra = {}) => ({ name: "w", repo: "o/r", phase: "running", started: NOW, claimed: [], inflight: [], merged: [], verified: [], unverified: [], closed: [], failed: [], folded: [], retries: {}, lanes: {}, batch: null, laneFor: {}, ...extra });
|
|
35
|
+
const run = (io, watch, state, ctx, o = {}) => decisionStep({ watch, repo: "o/r", io, state, ctx, shared: { matches: matchesSelectors, fold: async ({ issue }) => { state.folded.push(issue.number); }, labelNames, isBot, TRUSTED }, ...o });
|
|
36
|
+
const trusted = (c) => c.authorAssociation === "MEMBER";
|
|
37
|
+
const DECISION = "- Which retry strategy for the sync job?";
|
|
38
|
+
|
|
39
|
+
test("#4793 validateWatch: sweep_needs_human and max_launches", () => {
|
|
40
|
+
assert.equal(W().sweep_needs_human, false);
|
|
41
|
+
assert.equal(W().max_launches, null);
|
|
42
|
+
assert.equal(W({ sweep_needs_human: "true", max_launches: 2 }).sweep_needs_human, true);
|
|
43
|
+
assert.equal(W({ max_launches: "2" }).max_launches, 2);
|
|
44
|
+
assert.throws(() => W({ sweep_needs_human: "maybe" }), /sweep_needs_human/);
|
|
45
|
+
assert.throws(() => W({ max_launches: 0 }), /max_launches/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("#4793 stage is active for decisions.mode live or sweep_needs_human only", () => {
|
|
49
|
+
assert.equal(decisionsActive(W(), ctxOf("decisions:\n mode: live\n").policy), true);
|
|
50
|
+
assert.equal(decisionsActive(W(), ctxOf("decisions:\n mode: shadow\n").policy), false);
|
|
51
|
+
assert.equal(decisionsActive(W({ sweep_needs_human: true }), ctxOf("").policy), true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("#4793 escalationOf / terminalCodeOf read the escalation comment id and the typed code", () => {
|
|
55
|
+
const e = escalationOf([stateC("builder.blocked"), esc(77, DECISION)], trusted);
|
|
56
|
+
assert.equal(e.id, "77");
|
|
57
|
+
assert.match(e.text, /retry strategy/);
|
|
58
|
+
assert.equal(escalationOf([{ ...esc(5, "x"), authorAssociation: "CONTRIBUTOR" }], trusted), null);
|
|
59
|
+
assert.equal(terminalCodeOf("", [stateC("conformance.deferred")], trusted), "conformance.deferred");
|
|
60
|
+
assert.equal(terminalCodeOf("", [{ ...stateC("conformance.deferred"), authorAssociation: "CONTRIBUTOR" }], trusted), null); // #4793 SEC-1: a forged FORGE:STATE is not read
|
|
61
|
+
assert.equal(terminalCodeOf(`x <!-- FORGE:STATE\n${JSON.stringify({ terminalReason: "needs-human" })}\n-->`, [], trusted), "needs-human");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("#4793 live decision: needs-human cleared, handed to the engine once per escalation id", async () => {
|
|
65
|
+
const io = fakeIo([{ number: 1, comments: [stateC("builder.blocked"), esc(10, DECISION)] }]);
|
|
66
|
+
const w = W(), state = st(w), ctx = ctxOf();
|
|
67
|
+
const { rows } = await run(io, w, state, ctx);
|
|
68
|
+
assert.equal(rows[0].action, "decided");
|
|
69
|
+
assert.ok(io.writes.some((a) => a[1] === "edit" && a.includes("--remove-label") && a.includes("needs-human")));
|
|
70
|
+
assert.ok(io.writes.some((a) => a[1] === "comment" && /Escalation: 10/.test(a[a.indexOf("--body") + 1])));
|
|
71
|
+
await run(io, w, state, ctx); // the same escalation again: no second judge call
|
|
72
|
+
assert.equal(io.judged.length, 1);
|
|
73
|
+
assert.equal(decisionCounts(state).decided, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("#4793 selectors apply; an untrusted author's issue is ignored", async () => {
|
|
77
|
+
const io = fakeIo([{ number: 1, labels: ["area:x"], comments: [esc(10, DECISION)] }, { number: 2, comments: [esc(11, DECISION)] }, { number: 3, assoc: "NONE", labels: ["area:x"], comments: [esc(12, DECISION)] }]);
|
|
78
|
+
const w = W({ labels: "area:x" });
|
|
79
|
+
const { rows } = await run(io, w, st(w), ctxOf());
|
|
80
|
+
assert.deepEqual(rows.map((r) => r.issue), [1]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("#4793 backlog (escalation older than the watch) is opt-in via sweep_needs_human", async () => {
|
|
84
|
+
const old = esc(10, DECISION, "2026-10-29T00:00:00Z");
|
|
85
|
+
const a = fakeIo([{ number: 1, comments: [old] }]);
|
|
86
|
+
assert.equal((await run(a, W(), st(W()), ctxOf())).rows.length, 0);
|
|
87
|
+
assert.equal(a.judged.length, 0);
|
|
88
|
+
const w = W({ sweep_needs_human: true });
|
|
89
|
+
const b = fakeIo([{ number: 1, comments: [old] }]);
|
|
90
|
+
assert.equal((await run(b, w, st(w), ctxOf())).rows[0].action, "decided");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("#4793 --dry-run classifies every escalation and writes nothing", async () => {
|
|
94
|
+
const io = fakeIo([{ number: 1, comments: [stateC("conformance.deferred"), esc(10, "All items are post-deploy: nothing to build.")] }, { number: 2, comments: [esc(11, DECISION, "2026-10-29T00:00:00Z")] }]);
|
|
95
|
+
const w = W(), state = st(w);
|
|
96
|
+
const { rows } = await run(io, w, state, ctxOf(), { dryRun: true });
|
|
97
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.kind, !!r.backlog]), [[1, "post-deploy", false], [2, "decision", true]]);
|
|
98
|
+
assert.equal(io.writes.length, 0);
|
|
99
|
+
assert.equal(io.judged.length, 0);
|
|
100
|
+
assert.equal(state.decisions.rows.length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("#4793 max_issues and max_launches cap the stage", async () => {
|
|
104
|
+
const mk = () => fakeIo([1, 2, 3].map((n) => ({ number: n, comments: [esc(10 + n, DECISION)] })));
|
|
105
|
+
const w1 = W({ max_issues: 2 }), s1 = st(w1);
|
|
106
|
+
assert.equal((await run(mk(), w1, s1, ctxOf())).rows.length, 2);
|
|
107
|
+
const w2 = W({ max_launches: 1 }), s2 = st(w2);
|
|
108
|
+
assert.equal((await run(mk(), w2, s2, ctxOf(), { launch: () => true })).rows.length, 1);
|
|
109
|
+
const w3 = W({ max_issues: 2 }), s3 = st(w3, { claimed: [9] }); // an earlier claim uses a slot
|
|
110
|
+
assert.equal((await run(mk(), w3, s3, ctxOf())).rows.length, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("#4793 post-deploy-only escalation is folded into the rollup and closed", async () => {
|
|
114
|
+
const io = fakeIo([{ number: 1, comments: [stateC("conformance.deferred"), esc(10, "All items are post-deploy: nothing to build.")] }]);
|
|
115
|
+
const w = W({ postdeploy_rollup: 50 }), state = st(w);
|
|
116
|
+
const { rows } = await run(io, w, state, ctxOf());
|
|
117
|
+
assert.equal(rows[0].folded, true);
|
|
118
|
+
assert.deepEqual(state.folded, [1]);
|
|
119
|
+
assert.ok(io.writes.some((a) => a[1] === "close" && a[2] === "1"));
|
|
120
|
+
assert.equal(decisionCounts(state).folded, 1);
|
|
121
|
+
const none = fakeIo([{ number: 1, comments: [esc(10, "All items are post-deploy: nothing to build.")] }]);
|
|
122
|
+
const w2 = W(), s2 = st(w2); // no rollup: kept, never closed
|
|
123
|
+
assert.equal((await run(none, w2, s2, ctxOf())).rows[0].action, "post-deploy-kept");
|
|
124
|
+
assert.equal(none.writes.length, 0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("#4793 evidence escalation goes back to a prod-monitor lane only when forge.yaml declares a read-only source", async () => {
|
|
128
|
+
const text = "The check needs read-only production data we do not have.";
|
|
129
|
+
const declared = ctxOf("decisions:\n mode: live\nprod_watch:\n sources:\n am: {kind: alertmanager, run: am-alerts}\n");
|
|
130
|
+
assert.equal(declared.hasSource, true);
|
|
131
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, text)] }]);
|
|
132
|
+
const w = W(), state = st(w);
|
|
133
|
+
const { rows } = await run(io, w, state, declared, { launch: () => true });
|
|
134
|
+
assert.equal(rows[0].action, "evidence-needed");
|
|
135
|
+
assert.ok(io.writes.some((a) => a[1] === "comment" && /prod monitor/.test(a[a.indexOf("--body") + 1])));
|
|
136
|
+
assert.ok(io.writes.some((a) => a.includes("--remove-label")));
|
|
137
|
+
assert.deepEqual(state.decisions.launched, [1]);
|
|
138
|
+
assert.equal(decisionCounts(state).evidence, 1);
|
|
139
|
+
const io2 = fakeIo([{ number: 1, comments: [esc(10, text)] }]);
|
|
140
|
+
const none = ctxOf();
|
|
141
|
+
assert.equal(none.hasSource, false);
|
|
142
|
+
assert.equal((await run(io2, w, st(w), none)).rows[0].action, "evidence-kept");
|
|
143
|
+
assert.equal(io2.writes.length, 0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("#4793 external part keeps needs-human and counts as external-kept", async () => {
|
|
147
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, "- Rotate the production API key (credentials)")] }]);
|
|
148
|
+
const w = W(), state = st(w);
|
|
149
|
+
assert.equal((await run(io, w, state, ctxOf())).rows[0].action, "external");
|
|
150
|
+
assert.equal(decisionCounts(state).external_kept, 1);
|
|
151
|
+
assert.equal(io.writes.length, 0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("#4793 shadow mode posts the decision and keeps the label", async () => {
|
|
155
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, DECISION)] }]);
|
|
156
|
+
const w = W({ sweep_needs_human: true }), state = st(w);
|
|
157
|
+
assert.equal((await run(io, w, state, ctxOf("decisions:\n mode: shadow\n"))).rows[0].action, "shadow");
|
|
158
|
+
assert.ok(!io.writes.some((a) => a.includes("--remove-label")));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("#4793 shadow mode never folds, closes, clears the label or launches a post-deploy or evidence escalation", async () => {
|
|
162
|
+
const shadow = ctxOf("decisions:\n mode: shadow\nprod_watch:\n sources:\n am: {kind: alertmanager, run: am-alerts}\n");
|
|
163
|
+
const io = fakeIo([{ number: 1, comments: [stateC("conformance.deferred"), esc(10, "All items are post-deploy: nothing to build.")] }, { number: 2, comments: [esc(11, "The check needs read-only production data we do not have.")] }]);
|
|
164
|
+
const w = W({ sweep_needs_human: true, postdeploy_rollup: 50 }), state = st(w), launched = [];
|
|
165
|
+
const { rows } = await run(io, w, state, shadow, { launch: (i) => launched.push(i.number) });
|
|
166
|
+
assert.deepEqual(rows.map((r) => r.action), ["shadow", "shadow"]);
|
|
167
|
+
assert.deepEqual(state.folded, []);
|
|
168
|
+
assert.deepEqual(launched, []);
|
|
169
|
+
assert.ok(!io.writes.some((a) => a[1] === "close" || a.includes("--remove-label")));
|
|
170
|
+
assert.equal(io.writes.filter((a) => a[1] === "comment").length, 2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("#4793 a live decision relaunches the issue through the launch hook", async () => {
|
|
174
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, DECISION)] }]);
|
|
175
|
+
const w = W(), state = st(w), launched = [];
|
|
176
|
+
await run(io, w, state, ctxOf(), { launch: (i) => launched.push(i.number) });
|
|
177
|
+
assert.deepEqual(launched, [1]);
|
|
178
|
+
assert.deepEqual(state.decisions.launched, [1]);
|
|
179
|
+
assert.deepEqual(claimsUsed(state), { issues: 1, launches: 1 });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("#4793 a judge failure is not recorded as handled: retried, then left to a human after three rounds", async () => {
|
|
183
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, DECISION)] }], { judge: () => { throw new Error("claude timed out"); } });
|
|
184
|
+
const w = W(), state = st(w), ctx = ctxOf();
|
|
185
|
+
assert.equal((await run(io, w, state, ctx)).rows[0].action, "error");
|
|
186
|
+
assert.equal(state.decisions.handled["10"], undefined);
|
|
187
|
+
assert.equal(state.decisions.rows.length, 0);
|
|
188
|
+
await run(io, w, state, ctx); await run(io, w, state, ctx);
|
|
189
|
+
assert.equal(state.decisions.handled["10"], "error");
|
|
190
|
+
await run(io, w, state, ctx);
|
|
191
|
+
assert.equal(io.judged.length, 3);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("#4793 a NEEDS_HUMAN comment from an untrusted author is not an escalation", async () => {
|
|
195
|
+
const io = fakeIo([{ number: 1, comments: [{ ...esc(10, DECISION), authorAssociation: "CONTRIBUTOR" }] }]);
|
|
196
|
+
const w = W();
|
|
197
|
+
assert.equal((await run(io, w, st(w), ctxOf())).rows.length, 0);
|
|
198
|
+
assert.equal(io.judged.length, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("#4793 a veto label reverts a live decision once", async () => {
|
|
202
|
+
const issues = [{ number: 1, comments: [esc(10, DECISION)] }];
|
|
203
|
+
const io = fakeIo(issues);
|
|
204
|
+
const w = W(), state = st(w), ctx = ctxOf();
|
|
205
|
+
await run(io, w, state, ctx);
|
|
206
|
+
issues[0].labels = ["forge:decision-veto"];
|
|
207
|
+
await run(io, w, state, ctx);
|
|
208
|
+
assert.ok(io.writes.some((a) => a[1] === "edit" && a.includes("--add-label") && a.includes("needs-human")));
|
|
209
|
+
assert.equal(state.decisions.stats.reversed, 1);
|
|
210
|
+
assert.equal(state.decisions.live.length, 0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("#4793 formatStatus shows the decision counts only once the stage ran", async () => {
|
|
214
|
+
const w = W(), plain = st(w);
|
|
215
|
+
assert.ok(!/needs-human decisions/.test(formatStatus(plain, w, NOW)));
|
|
216
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, DECISION)] }]);
|
|
217
|
+
await run(io, w, plain, ctxOf());
|
|
218
|
+
assert.match(formatStatus(plain, w, NOW), /decided 1 evidence-gathered 0 folded 0 external-kept 0/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("#4793 runWatch runs the stage under decisions.mode: live, stays inert otherwise, and max_launches stops claims", async () => {
|
|
222
|
+
const mkCwd = (yaml) => { const d = tmpDir("iwc-"); writeFileSync(join(d, "NORTHSTAR.md"), "# star"); if (yaml) writeFileSync(join(d, "forge.yaml"), yaml); return d; };
|
|
223
|
+
const base = (n, i = {}) => ({ number: n, title: `t${n}`, body: "", labels: [], milestone: null, authorAssociation: "OWNER", author: { login: "me" }, createdAt: "2026-10-01T00:00:00Z", ...i });
|
|
224
|
+
const go = async (yaml, w) => {
|
|
225
|
+
const issues = [base(1, { labels: [{ name: "needs-human" }] }), base(2), base(3)], writes = [], launched = [];
|
|
226
|
+
const dir = tmpDir("iwd-");
|
|
227
|
+
const io = { env: { FORGEDOCK_WATCH_DIR: dir }, now: () => NOW, sleep: async () => {}, requiredChecks: async () => [], judge: async () => good,
|
|
228
|
+
gh: async (a) => {
|
|
229
|
+
if (a[0] === "api") return issues.map((i) => `${i.number}\tOWNER`).join("\n");
|
|
230
|
+
if (a[1] === "list") return JSON.stringify((a.includes("--label") ? issues.filter((i) => i.labels.length) : issues.filter((i) => !i.labels.length)).map(({ authorAssociation: _a, ...i }) => i));
|
|
231
|
+
if (a[1] === "view" && a.includes("comments,body,labels")) return JSON.stringify({ comments: [esc(10, DECISION)], body: "", labels: [] });
|
|
232
|
+
if (a[1] === "view") return JSON.stringify({ state: "OPEN", comments: [], labels: [] });
|
|
233
|
+
writes.push(a);
|
|
234
|
+
return "";
|
|
235
|
+
},
|
|
236
|
+
launch: (n) => { launched.push(n); return { poll: () => null }; } };
|
|
237
|
+
const state = await runWatch({ watch: w, repo: "o/r", io, dir, branches: {}, defaults: [], once: true, cwd: mkCwd(yaml) });
|
|
238
|
+
return { state, writes, launched };
|
|
239
|
+
};
|
|
240
|
+
const inert = await go("decisions:\n mode: shadow\n", W());
|
|
241
|
+
assert.equal(inert.state.decisions, undefined);
|
|
242
|
+
assert.equal(inert.writes.length, 0);
|
|
243
|
+
const live = await go("decisions:\n mode: live\n", W());
|
|
244
|
+
assert.equal(live.state.decisions.rows[0].action, "decided");
|
|
245
|
+
assert.ok(live.writes.some((a) => a.includes("--remove-label")));
|
|
246
|
+
assert.deepEqual(live.launched, [1, 2, 3]); // the decided issue is relaunched, then the watch claims the rest
|
|
247
|
+
const capped = await go("", W({ max_launches: 2 }));
|
|
248
|
+
assert.equal(capped.launched.length, 2);
|
|
249
|
+
assert.deepEqual(capped.state.claimed.length, 2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("#4793 the engine's own App login counts as trusted though it is a CONTRIBUTOR; another CONTRIBUTOR does not", async () => {
|
|
253
|
+
const from = (login) => ({ ...esc(10, DECISION), authorAssociation: "CONTRIBUTOR", author: { login } });
|
|
254
|
+
const w = W();
|
|
255
|
+
assert.equal((await run(fakeIo([{ number: 1, comments: [from("rapiercraft-forgedock")] }]), w, st(w), ctxOf())).rows.length, 1);
|
|
256
|
+
assert.equal((await run(fakeIo([{ number: 1, comments: [from("some-contributor")] }]), w, st(w), ctxOf())).rows.length, 0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("#4793 judge calls are capped per round even when max_issues is unset", async () => {
|
|
260
|
+
const issues = Array.from({ length: 30 }, (_, k) => ({ number: k + 1, comments: [{ ...esc(100 + k, DECISION), url: `https://x/issues/${k + 1}#issuecomment-${100 + k}` }] }));
|
|
261
|
+
const io = fakeIo(issues);
|
|
262
|
+
const w = W(), state = st(w);
|
|
263
|
+
assert.equal(w.max_issues, null);
|
|
264
|
+
await run(io, w, state, ctxOf());
|
|
265
|
+
assert.equal(io.judged.length, 20);
|
|
266
|
+
await run(io, w, state, ctxOf()); // the rest is picked up next round
|
|
267
|
+
assert.equal(io.judged.length, 30);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("release #5068 MAT-1: a batch watch requeues a claimed issue into its batch; release and unclaimed issues release the claim", () => {
|
|
271
|
+
const calls = [];
|
|
272
|
+
const io = { batch: { requeue: (id, n) => { calls.push([id, n]); return true; } } };
|
|
273
|
+
const b = W({ mode: "batch" }), sb = st(b, { batch: "b1", claimed: [5], failed: [5] });
|
|
274
|
+
assert.equal(decisionLaunch({ i: { number: 5 }, watch: b, repo: "o/r", io, state: sb, handles: new Map() }), true);
|
|
275
|
+
assert.deepEqual(calls, [["b1", 5]]);
|
|
276
|
+
assert.deepEqual(sb.failed, []);
|
|
277
|
+
assert.deepEqual(sb.claimed, [5]); // still the batch's
|
|
278
|
+
const refused = { batch: { requeue: () => false } };
|
|
279
|
+
assert.equal(decisionLaunch({ i: { number: 5 }, watch: b, repo: "o/r", io: refused, state: st(b, { batch: "b1", claimed: [5] }), handles: new Map() }), false);
|
|
280
|
+
const sb2 = st(b, { batch: "b1", claimed: [] }); // a backlog issue the batch never took: the next tick adds it
|
|
281
|
+
assert.equal(decisionLaunch({ i: { number: 6 }, watch: b, repo: "o/r", io, state: sb2, handles: new Map() }), true);
|
|
282
|
+
assert.deepEqual(calls.length, 1);
|
|
283
|
+
const r = W({ mode: "release", immediate: ["priority:P0"] }), sr = st(r, { claimed: [7, 8], lanes: { 7: "staging" }, failed: [7] });
|
|
284
|
+
assert.equal(decisionLaunch({ i: { number: 7 }, watch: r, repo: "o/r", io, state: sr, handles: new Map() }), true);
|
|
285
|
+
assert.deepEqual(sr.claimed, [8]); // re-selectable by the next release pass
|
|
286
|
+
assert.deepEqual(sr.failed, []);
|
|
287
|
+
const p = W(); // per-issue: an inflight lane is never launched twice
|
|
288
|
+
assert.equal(decisionLaunch({ i: { number: 9 }, watch: p, repo: "o/r", io, state: st(p, { inflight: [9] }), handles: new Map() }), false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("release #5068 MAT-4: a launch that does not happen is not counted against max_launches", async () => {
|
|
292
|
+
const io = fakeIo([{ number: 1, comments: [esc(10, DECISION)] }]);
|
|
293
|
+
const w = W(), state = st(w);
|
|
294
|
+
await run(io, w, state, ctxOf(), { launch: () => false });
|
|
295
|
+
assert.deepEqual(state.decisions.launched, []);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("release #5068 MAT-2: one unreadable issue does not end the round", async () => {
|
|
299
|
+
const io = fakeIo([1, 2].map((n) => ({ number: n, comments: [esc(10 + n, DECISION)] })));
|
|
300
|
+
const gh = io.gh;
|
|
301
|
+
io.gh = async (a) => { if (a[1] === "view" && a[2] === "1") throw new Error("HTTP 502"); return gh(a); };
|
|
302
|
+
const w = W(), state = st(w);
|
|
303
|
+
const { rows } = await run(io, w, state, ctxOf(), { launch: () => true });
|
|
304
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.action]), [[1, "error"], [2, "decided"]]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("release #5068 BUG-1: a live decision whose label clear failed is finished next round, not skipped", async () => {
|
|
308
|
+
const posted = { ...esc(20, "x"), body: "<!-- FORGE:DECISION -->\n## Decision\nEscalation: 11\n### Decision 1\nuse A" };
|
|
309
|
+
const io = fakeIo([{ number: 1, comments: [{ ...esc(11, DECISION), url: "https://x/issues/1#issuecomment-11" }, posted] }]);
|
|
310
|
+
const w = W(), state = st(w), launched = [];
|
|
311
|
+
const { rows } = await run(io, w, state, ctxOf(), { launch: (i) => launched.push(i.number) });
|
|
312
|
+
assert.equal(rows[0].action, "decided");
|
|
313
|
+
assert.ok(io.writes.some((a) => a.includes("--remove-label")));
|
|
314
|
+
assert.deepEqual(launched, [1]);
|
|
315
|
+
assert.equal(io.judged.length, 0); // not judged again
|
|
316
|
+
assert.equal(state.decisions.stats.decided, 0); // not counted twice
|
|
317
|
+
const vetoed = { ...posted, body: "<!-- FORGE:DECISION -->\n## Decision vetoed\nEscalation: 11\n" };
|
|
318
|
+
const io2 = fakeIo([{ number: 1, comments: [{ ...esc(11, DECISION), url: "https://x/issues/1#issuecomment-11" }, posted, vetoed] }]);
|
|
319
|
+
const s2 = st(w);
|
|
320
|
+
await run(io2, w, s2, ctxOf(), { launch: () => true });
|
|
321
|
+
assert.ok(!io2.writes.some((a) => a.includes("--remove-label")));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("release #5068 CQ-1: a failed decision round is shown in the watch status", () => {
|
|
325
|
+
const w = W();
|
|
326
|
+
assert.match(formatStatus({ ...st(w), decisionError: "gh: HTTP 502" }, w, NOW), /needs-human decisions: last round failed: gh: HTTP 502/);
|
|
327
|
+
assert.ok(!/last round failed/.test(formatStatus(st(w), w, NOW)));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("release #5068 SEC-1: a forged FORGE:DECISION from an untrusted commenter neither finishes nor suppresses a decision", async () => {
|
|
331
|
+
const forged = { ...esc(20, "x"), authorAssociation: "NONE", author: { login: "stranger" }, body: "<!-- FORGE:DECISION -->\n## Decision\nEscalation: 11\n" };
|
|
332
|
+
const io = fakeIo([{ number: 1, comments: [{ ...esc(11, DECISION), url: "https://x/issues/1#issuecomment-11" }, forged] }]);
|
|
333
|
+
const w = W(), state = st(w);
|
|
334
|
+
const { rows } = await run(io, w, state, ctxOf(), { launch: () => true });
|
|
335
|
+
assert.equal(rows[0].action, "decided");
|
|
336
|
+
assert.equal(rows[0].resumed, undefined); // judged afresh, not "finished"
|
|
337
|
+
assert.deepEqual(io.judged, [1]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("release #5068 delta SPEC-1: a batch issue still inflight in its running batch is requeued; after the batch reported its claim is released", () => {
|
|
341
|
+
const calls = [];
|
|
342
|
+
const io = { batch: { requeue: (id, n) => { calls.push([id, n]); return true; } } };
|
|
343
|
+
const b = W({ mode: "batch" });
|
|
344
|
+
const running = st(b, { batch: "b1", claimed: [5], inflight: [5] });
|
|
345
|
+
assert.equal(decisionLaunch({ i: { number: 5 }, watch: b, repo: "o/r", io, state: running, handles: new Map() }), true);
|
|
346
|
+
assert.deepEqual(calls, [["b1", 5]]);
|
|
347
|
+
assert.deepEqual(running.inflight, [5]);
|
|
348
|
+
const reported = st(b, { batch: null, claimed: [5], failed: [5] });
|
|
349
|
+
assert.equal(decisionLaunch({ i: { number: 5 }, watch: b, repo: "o/r", io, state: reported, handles: new Map() }), true);
|
|
350
|
+
assert.deepEqual(reported.claimed, []);
|
|
351
|
+
assert.deepEqual(reported.failed, []);
|
|
352
|
+
assert.equal(calls.length, 1);
|
|
353
|
+
});
|
|
@@ -238,7 +238,9 @@ test("CLI: --dry-run prints the cut without writing; refuses a non-release watch
|
|
|
238
238
|
const r = await issueWatchFromCli(["run", "--name", "sr", "--dry-run"], { cwd, env: io.env, io, print: (l) => out.push(l) });
|
|
239
239
|
assert.match(out.join("\n"), /would cut: billing \(2 issues/);
|
|
240
240
|
assert.equal(io.batches.length, 0); assert.ok(r.dryRun);
|
|
241
|
-
|
|
241
|
+
const plain = [];
|
|
242
|
+
await issueWatchFromCli(["run", "--name", "plain", "--dry-run"], { cwd, env: io.env, io, print: (l) => plain.push(l) }); // #4793: a non-release watch dry-run classifies needs-human escalations
|
|
243
|
+
assert.match(plain.join("\n"), /no needs-human escalations to classify/);
|
|
242
244
|
const w = loadWatches(`issue_watch:\n watches:\n sr:\n mode: release\n threshold: { min_issues: 2 }\n`).sr;
|
|
243
245
|
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
246
|
});
|
package/bin/lean/issuewatch.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* No LLM calls.
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, rmdirSync } from "node:fs";
|
|
13
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import { homedir, hostname } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -19,15 +19,19 @@ import { milestoneSlug, readBranches, isDefaultLane, defaultBranches, DEFAULT_ST
|
|
|
19
19
|
import { postDeployOnly } from "./prefilter.mjs";
|
|
20
20
|
import { releaseTick, formatDryRun } from "./issuewatch-release.mjs";
|
|
21
21
|
import { loadPolicy } from "./decision-engine.mjs";
|
|
22
|
+
import { decisionContext, decisionsActive, decisionStep, decisionCounts, formatDecisionDryRun, claimsUsed } from "./issuewatch-decisions.mjs";
|
|
22
23
|
import { APP_SLUG } from "../init-repo.mjs";
|
|
23
24
|
import { noComments } from "./render.mjs";
|
|
24
25
|
import { listAssociations } from "./gh-assoc.mjs";
|
|
25
26
|
import { verifyMerge, verifyComment } from "./issuewatch-verify.mjs";
|
|
26
27
|
|
|
27
|
-
const WATCH_KEYS = ["milestone", "labels", "exclude_labels", "search", "title_regex", "since", "version", "lane", "max_issues", "max_inflight", "until", "mode", "parallel", "include_followups", "postdeploy_rollup", "threshold", "immediate", "max_release_issues", "theme_map", "tracker"];
|
|
28
|
-
const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
28
|
+
const WATCH_KEYS = ["milestone", "labels", "exclude_labels", "search", "title_regex", "since", "version", "lane", "max_issues", "max_inflight", "until", "mode", "parallel", "include_followups", "postdeploy_rollup", "threshold", "immediate", "max_release_issues", "theme_map", "tracker", "sweep_needs_human", "max_launches"];
|
|
29
|
+
export const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
29
30
|
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
31
|
const RETRIES = 3;
|
|
32
|
+
const readFile = (f) => readFileSync(f, "utf8");
|
|
33
|
+
const JUDGE_TIMEOUT_MS = 120_000; // a hung `claude --print` must not stall the watch round
|
|
34
|
+
const CLAUDE_MAX_BUFFER = 8 * 1024 * 1024;
|
|
31
35
|
const VERIFY_WAIT_MS = 60 * 60000; // how long a merge commit's CI may stay pending before verification fails
|
|
32
36
|
const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
|
|
33
37
|
|
|
@@ -74,7 +78,9 @@ export function validateWatch(name, cfg = {}) {
|
|
|
74
78
|
};
|
|
75
79
|
const w = { name, milestone: cfg.milestone || null, labels: list(cfg.labels), exclude_labels: list(cfg.exclude_labels), search: cfg.search || null, title_regex: cfg.title_regex || null,
|
|
76
80
|
since: cfg.since || null, version: cfg.version ? String(cfg.version) : null, lane: cfg.lane || null, max_issues: int("max_issues", null), max_inflight: int("max_inflight", 4),
|
|
77
|
-
until: cfg.until || null, mode: cfg.mode || "per-issue", parallel: int("parallel", 4), include_followups: cfg.include_followups || "none", postdeploy_rollup: cfg.postdeploy_rollup ? Number(String(cfg.postdeploy_rollup).replace("#", "")) : null
|
|
81
|
+
until: cfg.until || null, mode: cfg.mode || "per-issue", parallel: int("parallel", 4), include_followups: cfg.include_followups || "none", postdeploy_rollup: cfg.postdeploy_rollup ? Number(String(cfg.postdeploy_rollup).replace("#", "")) : null,
|
|
82
|
+
max_launches: int("max_launches", null), sweep_needs_human: cfg.sweep_needs_human === true || String(cfg.sweep_needs_human).toLowerCase() === "true" };
|
|
83
|
+
if (cfg.sweep_needs_human !== undefined && !/^(true|false)$/i.test(String(cfg.sweep_needs_human))) throw new Error(`watch ${name}: sweep_needs_human must be true or false`);
|
|
78
84
|
if (!["per-issue", "batch", "release"].includes(w.mode)) throw new Error(`watch ${name}: mode must be per-issue, batch or release`);
|
|
79
85
|
if (w.mode === "release") validateRelease(name, cfg, w, int);
|
|
80
86
|
else for (const k of ["threshold", "immediate", "max_release_issues", "theme_map", "tracker"]) if (cfg[k] !== undefined) throw new Error(`watch ${name}: ${k} needs mode: release`);
|
|
@@ -107,7 +113,7 @@ function validateRelease(name, cfg, w, int) {
|
|
|
107
113
|
export const loadWatches = (raw) => Object.fromEntries(Object.entries(parseWatches(raw)).map(([n, c]) => [n, validateWatch(n, c)]));
|
|
108
114
|
|
|
109
115
|
// ── selectors ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
110
|
-
const labelNames = (i) => (i.labels ?? []).map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean);
|
|
116
|
+
export const labelNames = (i) => (i.labels ?? []).map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean);
|
|
111
117
|
|
|
112
118
|
/** An issue's target version: x.y from its milestone title, else from a `version:x.y` label; null when none. */
|
|
113
119
|
export function issueVersion(issue) {
|
|
@@ -135,7 +141,7 @@ export const hasUnresolvedDefer = (body) => noComments(body).split("\n").some((l
|
|
|
135
141
|
|
|
136
142
|
// Bot authorship comes from the author only, never the body: a `FORGE:DEFERRED` marker in an outsider's issue is not
|
|
137
143
|
// the App. `gh` reports the ForgeDock App as a bare login with no `[bot]` suffix (#4467), so it is matched by slug.
|
|
138
|
-
const isBot = (i) => { const l = String(i.author?.login ?? ""); return /\[bot\]$|^app\//i.test(l) || i.author?.is_bot === true || l.toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "") === APP_SLUG; };
|
|
144
|
+
export const isBot = (i) => { const l = String(i.author?.login ?? ""); return /\[bot\]$|^app\//i.test(l) || i.author?.is_bot === true || l.toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "") === APP_SLUG; };
|
|
139
145
|
const isFollowup = (i) => /^follow-up\b/i.test(i.title ?? "");
|
|
140
146
|
|
|
141
147
|
/** `claim` (a lane/batch takes it), `fold` (post-deploy-only follow-up for the rollup), or null (not for this watch). */
|
|
@@ -270,11 +276,12 @@ export async function tick({ watch, repo, io, state, dir, branches = {}, default
|
|
|
270
276
|
for (const n of [...(state.pendingVerify ?? [])]) if (await closed(n)) await verify(n);
|
|
271
277
|
if (watch.mode === "batch" && state.batch && io.batch.reported(state.batch)) { state.inflight.forEach(fail); state.batch = null; }
|
|
272
278
|
|
|
273
|
-
const
|
|
279
|
+
const used = claimsUsed(state); // the decision stage (#4793) draws on the same caps
|
|
280
|
+
const capped = (watch.max_issues && used.issues >= watch.max_issues) || (watch.max_launches && used.launches >= watch.max_launches) || (watch.until && now >= Date.parse(watch.until));
|
|
274
281
|
if (capped && state.phase === "running") state.phase = "draining";
|
|
275
282
|
if (state.phase === "draining") { if (!state.inflight.length && !Object.keys(state.verifyFrom ?? {}).length) state.phase = "done"; return state; } // a merge mid-verification holds the drain
|
|
276
283
|
|
|
277
|
-
const room = Math.min(watch.max_inflight - state.inflight.length, watch.max_issues ? watch.max_issues -
|
|
284
|
+
const room = Math.min(watch.max_inflight - state.inflight.length, watch.max_issues ? watch.max_issues - used.issues : Infinity, watch.max_launches ? watch.max_launches - used.launches : Infinity);
|
|
278
285
|
const args = ["issue", "list", "-R", repo, "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,author,createdAt"];
|
|
279
286
|
if (watch.search) args.push("--search", watch.search);
|
|
280
287
|
const candidates = [];
|
|
@@ -316,7 +323,7 @@ async function releaseStep({ watch, repo, io, state, dir, branches = {}, default
|
|
|
316
323
|
return all;
|
|
317
324
|
};
|
|
318
325
|
const eligible = (i) => matchesSelectors(i, watch) && classifyIssue(i, watch) === "claim" && !state.claimed.includes(i.number) && unclaimedOrOwn(registry(), i.number, watch.name); // release #4820 CONC-1: own claims stay eligible
|
|
319
|
-
const p = loadPolicy({ io: { readFile
|
|
326
|
+
const p = loadPolicy({ io: { readFile }, cwd });
|
|
320
327
|
return releaseTick({ watch, repo, io, state, defaults, listOpen, eligible, claim: (n) => claimIssue(repoDir(repo, io.env), n, watch.name), laneFor: (i) => laneFor(i, watch, { branches, defaults }), dagOrder,
|
|
321
328
|
policy: { path: p.policyPath, text: p.policyText }, dryRun });
|
|
322
329
|
}
|
|
@@ -336,15 +343,52 @@ export function formatStatus(s, watch, now = Date.now()) {
|
|
|
336
343
|
const left = watch.until ? `${Math.max(0, Math.round((Date.parse(watch.until) - now) / 60000))}m` : "forever";
|
|
337
344
|
return [`watch ${s.name} (${s.repo}) [${s.phase}, ${watch.mode}]`, ` selector: ${sel}`,
|
|
338
345
|
` claims: ${s.claimed.length}/${watch.max_issues ?? "∞"} inflight: ${s.inflight.length} merged: ${s.merged.length} verified: ${s.verified.length} unverified: ${(s.unverified ?? []).length} closed unmerged: ${(s.closed ?? []).length} failed: ${s.failed.length}`, ` time remaining: ${left}`,
|
|
346
|
+
...(s.decisions ? [(({ decided, evidence, folded, external_kept: kept }) => ` needs-human decisions: decided ${decided} evidence-gathered ${evidence} folded ${folded} external-kept ${kept}`)(decisionCounts(s))] : []),
|
|
347
|
+
...(s.decisionError ? [` needs-human decisions: last round failed: ${s.decisionError}`] : []), // release #5068 CQ-1
|
|
339
348
|
...(watch.mode === "release" ? [` pool: ${(s.pool ?? []).length} releases: ${(s.releases ?? []).length + (s.release ? 1 : 0)}${s.release ? ` active: ${s.release.theme} (${s.release.issues.length} issues, batch ${s.release.batch})` : ""} llm-classified: ${s.llm ?? 0}`] : [])].join("\n");
|
|
340
349
|
}
|
|
341
350
|
|
|
351
|
+
const unsettle = (state, n) => { for (const k of ["failed", "unverified", "closed", "pendingVerify"]) if (state[k]) state[k] = state[k].filter((x) => x !== n); delete state.retries[n]; };
|
|
352
|
+
/**
|
|
353
|
+
* #4793: a decided or evidence-gathered issue (needs-human already cleared) goes back to a lane. Per-issue watches launch it
|
|
354
|
+
* here. Release #5068 MAT-1: batch and release watches never re-select an issue they hold in `claimed`, so a batch watch
|
|
355
|
+
* requeues it into its batch (`orchestrate --requeue`), and an issue the watch has not taken yet, or a release watch's,
|
|
356
|
+
* has its claim released so the next pass selects it. @returns {boolean} true when a lane runs or the next pass takes it.
|
|
357
|
+
*/
|
|
358
|
+
export function decisionLaunch({ i, watch, repo, io, state, handles, branches, defaults }) {
|
|
359
|
+
const n = i.number;
|
|
360
|
+
// release #5068 delta SPEC-1: a batch issue stays inflight until its batch reports, so a running batch is requeued
|
|
361
|
+
// whatever inflight says; once the batch reported (tick moved it to failed, state.batch null) the claim is released
|
|
362
|
+
if (watch.mode === "batch" && state.batch && state.claimed.includes(n)) {
|
|
363
|
+
if (!io.batch?.requeue?.(state.batch, n, "issue-watch decision: needs-human settled")) return false;
|
|
364
|
+
unsettle(state, n);
|
|
365
|
+
if (!state.inflight.includes(n)) state.inflight.push(n);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
if (watch.mode !== "per-issue") { // the next tick / release pass re-selects it (own registry claims stay eligible)
|
|
369
|
+
state.claimed = state.claimed.filter((x) => x !== n); state.inflight = state.inflight.filter((x) => x !== n); delete state.lanes[n];
|
|
370
|
+
unsettle(state, n);
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (state.inflight.includes(n) || !claimIssue(repoDir(repo, io.env), n, watch.name)) return false;
|
|
374
|
+
const lane = laneFor(i, watch, { branches, defaults });
|
|
375
|
+
if (!state.claimed.includes(n)) state.claimed.push(n);
|
|
376
|
+
unsettle(state, n); state.inflight.push(n); state.lanes[n] = lane; handles.set(n, io.launch(n, lane));
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
342
380
|
/** Run until done (capped) or `once`; writes status.json each pass. */
|
|
343
381
|
export async function runWatch({ watch, repo, io, dir, branches, defaults, once = false, intervalMs = 300000, release = () => {}, cwd }) {
|
|
344
382
|
const state = readJson(join(dir, "status.json")) ?? fresh(watch, repo);
|
|
345
383
|
const handles = new Map();
|
|
384
|
+
const ctx = decisionContext({ loadPolicy, readFile, cwd: cwd ?? process.cwd() });
|
|
385
|
+
const shared = { matches: matchesSelectors, fold, labelNames, isBot, TRUSTED };
|
|
386
|
+
const launch = (i) => decisionLaunch({ i, watch, repo, io, state, handles, branches, defaults });
|
|
346
387
|
try {
|
|
347
388
|
do {
|
|
389
|
+
if (state.phase === "running" && decisionsActive(watch, ctx.policy)) { // #4793: a failed round must not stop the watch
|
|
390
|
+
try { await decisionStep({ watch, repo, io, state, ctx, shared, launch }); state.decisionError = null; } catch (e) { state.decisionError = String(e?.message ?? e).slice(0, 200); }
|
|
391
|
+
}
|
|
348
392
|
if (watch.mode === "release") await releaseStep({ watch, repo, io, state, dir, branches, defaults, cwd }); else await tick({ watch, repo, io, state, dir, branches, defaults, handles });
|
|
349
393
|
writeAtomic(join(dir, "status.json"), JSON.stringify(state) + "\n");
|
|
350
394
|
if (state.phase === "done" || once) break;
|
|
@@ -355,6 +399,7 @@ export async function runWatch({ watch, repo, io, dir, branches, defaults, once
|
|
|
355
399
|
}
|
|
356
400
|
|
|
357
401
|
// ── real io + CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
const judgePrompt = ({ decision, options, policy, policyPath, precedent }) => `You settle one decision for a repository from its own policy file (${policyPath ?? "none"}) and prior decisions. Reply with only a JSON object {"choice","clause","rejected","reversibility":"reversible|one-way","confidence":"high|medium|low","anchors":[]}. "clause" quotes the policy clause or precedent that supports the choice; leave it empty when none applies.\n\nPolicy:\n${policy}\n\nPrior decisions:\n${JSON.stringify((precedent ?? []).slice(0, 20))}\n\nDecision needed:\n${decision}\n${(options ?? []).length ? `Options: ${options.join(" | ")}\n` : ""}`;
|
|
358
403
|
const CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "forgedock.mjs");
|
|
359
404
|
function realIo(repo, env = process.env) {
|
|
360
405
|
const gh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -364,6 +409,10 @@ function realIo(repo, env = process.env) {
|
|
|
364
409
|
const prompt = `Classify each GitHub issue into exactly one theme of [${themes.join(", ")}], or "general" when none fits. Reply with only a JSON object {"<number>": "<theme>"}.\n\n${issues.map((i) => `#${i.number} ${i.title}\n${String(i.body ?? "").slice(0, 600)}`).join("\n---\n")}`;
|
|
365
410
|
return JSON.parse(/\{[\s\S]*\}/.exec(execFileSync("claude", ["--print"], { input: prompt, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }))[0]);
|
|
366
411
|
},
|
|
412
|
+
judge: ({ decision, options, policy, policyPath, precedent }) => { // #4793: the one LLM judgment of the decision stage
|
|
413
|
+
const prompt = judgePrompt({ decision, options, policy, policyPath, precedent });
|
|
414
|
+
return JSON.parse(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/.exec(execFileSync("claude", ["--print"], { input: prompt, encoding: "utf8", maxBuffer: CLAUDE_MAX_BUFFER, timeout: JUDGE_TIMEOUT_MS }))[0]);
|
|
415
|
+
},
|
|
367
416
|
launch: (n, lane) => {
|
|
368
417
|
let res = null;
|
|
369
418
|
const p = spawn(process.execPath, [CLI, "run-issue", String(n), "--lane", lane], { detached: true, windowsHide: true, stdio: "ignore", cwd: process.cwd(), env });
|
|
@@ -372,6 +421,8 @@ function realIo(repo, env = process.env) {
|
|
|
372
421
|
},
|
|
373
422
|
batch: {
|
|
374
423
|
add: (id, issues) => openBatch(root, id).addIssues(issues),
|
|
424
|
+
// release #5068 MAT-1: the CLI path checks the label, reopens a finished batch and hosts it on the schedd
|
|
425
|
+
requeue: (id, n, reason) => (spawnSync(process.execPath, [CLI, "orchestrate", "--requeue", id, String(n), "--reason", reason], { cwd: process.cwd(), env, encoding: "utf8", timeout: 120000, windowsHide: true }).stdout ?? "").match(new RegExp(`requeued #${n}\\b`)) !== null,
|
|
375
426
|
reported: (id) => existsSync(join(root, id, "report.json")),
|
|
376
427
|
start: async (issues, parallel, lane) => {
|
|
377
428
|
const before = new Set(listBatches(root));
|
|
@@ -403,13 +454,19 @@ export async function issueWatchFromCli(argv, deps = {}) {
|
|
|
403
454
|
print(s ? formatStatus(s, watch, io.now()) : `watch ${name}: no status yet`);
|
|
404
455
|
return { ok: true };
|
|
405
456
|
}
|
|
457
|
+
const ctx = decisionContext({ loadPolicy, readFile, cwd }), shared = { matches: matchesSelectors, fold, labelNames, isBot, TRUSTED };
|
|
458
|
+
if (argv.includes("--dry-run") && watch.mode !== "release") { // #4793: classification of the needs-human escalations, nothing written
|
|
459
|
+
const { rows } = await decisionStep({ watch, repo, io, state: readJson(join(dir, "status.json")) ?? fresh(watch, repo), ctx, shared, dryRun: true });
|
|
460
|
+
print(formatDecisionDryRun(rows));
|
|
461
|
+
return { ok: true, dryRun: { rows } };
|
|
462
|
+
}
|
|
406
463
|
if (argv.includes("--dry-run")) {
|
|
407
|
-
if (watch.mode !== "release") throw new Error("--dry-run needs a watch with mode: release");
|
|
408
464
|
const real = deps.io ?? realIo(repo, env), branches = readBranches(cwd);
|
|
409
465
|
const defaults = await defaultBranches({ repo, io: { gh: async (a) => real.gh(a) }, branches });
|
|
410
466
|
const state = readJson(join(dir, "status.json")) ?? fresh(watch, repo);
|
|
411
467
|
const r = await releaseStep({ watch, repo, io: real, state, dir, branches, defaults, dryRun: true, cwd });
|
|
412
468
|
print(r.decision ? formatDryRun(r.decision, r.pool, r.themes) : "release in flight; nothing to cut");
|
|
469
|
+
if (decisionsActive(watch, ctx.policy)) print(formatDecisionDryRun((await decisionStep({ watch, repo, io: real, state, ctx, shared, dryRun: true })).rows));
|
|
413
470
|
return { ok: true, dryRun: r };
|
|
414
471
|
}
|
|
415
472
|
mkdirSync(dir, { recursive: true });
|
|
@@ -401,3 +401,21 @@ for (const [what, bad] of [["label", (a) => a[1] === "edit" && a.includes("needs
|
|
|
401
401
|
assert.deepEqual(state.pendingVerify, []);
|
|
402
402
|
});
|
|
403
403
|
}
|
|
404
|
+
|
|
405
|
+
test("#4793 sweep_needs_human and max_launches are watch keys; max_launches caps the claims of a tick", async () => {
|
|
406
|
+
assert.equal(W({ sweep_needs_human: true, max_launches: 1 }).max_launches, 1);
|
|
407
|
+
assert.throws(() => W({ max_launches: "x" }), /max_launches/);
|
|
408
|
+
const env = tmp();
|
|
409
|
+
const io = fakeIo(env, [iss(1), iss(2), iss(3)]);
|
|
410
|
+
const w = W({ max_launches: 2 });
|
|
411
|
+
const state = await runWatch({ watch: w, repo: "o/r", io, dir: env, branches: {}, defaults: [], once: true, cwd: tmp() });
|
|
412
|
+
assert.equal(state.claimed.length, 2);
|
|
413
|
+
assert.equal(io.launched.length, 2);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("#4793 formatStatus lists decided / evidence-gathered / folded / external-kept from the stage rows", () => {
|
|
417
|
+
const w = W();
|
|
418
|
+
const s = { name: "w", repo: "o/r", phase: "running", claimed: [], inflight: [], merged: [], verified: [], failed: [],
|
|
419
|
+
decisions: { rows: [{ action: "decided" }, { action: "evidence-needed" }, { action: "post-deploy-only" }, { action: "external", kept: true }, { action: "external", kept: true }] } };
|
|
420
|
+
assert.match(formatStatus(s, w, 0), /needs-human decisions: decided 1 evidence-gathered 1 folded 1 external-kept 2/);
|
|
421
|
+
});
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -21,6 +21,19 @@ export function milestoneSlug(title) {
|
|
|
21
21
|
return String(title ?? "").toLowerCase().replace(/ /g, "-").replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Every milestone of `repo` in `state` (all|open|closed), across every page (#5084: one page of 100 hid newer milestones).
|
|
26
|
+
* One `gh api --paginate` call, one object per line; a whole-JSON reply (a single array) is accepted too. gh errors propagate.
|
|
27
|
+
*/
|
|
28
|
+
export async function listMilestones({ gh, repo, state = "all" }) {
|
|
29
|
+
const text = String(await gh(["api", `repos/${repo}/milestones?state=${state}&per_page=100`, "--paginate", "--jq", ".[] | @json"]) ?? "").trim();
|
|
30
|
+
if (!text) return [];
|
|
31
|
+
let whole;
|
|
32
|
+
try { whole = JSON.parse(text); } catch { whole = undefined; }
|
|
33
|
+
if (whole !== undefined) return Array.isArray(whole) ? whole : whole && typeof whole === "object" ? [whole] : [];
|
|
34
|
+
return text.split("\n").map((l) => l.trim()).filter(Boolean).flatMap((l) => JSON.parse(l));
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/** forge.yaml `branches` from the checkout: {default, staging, featurePattern}; missing keys are null. */
|
|
25
38
|
export function readBranches(cwd = process.cwd()) {
|
|
26
39
|
let b = {};
|