forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/precheck.mjs
CHANGED
|
@@ -42,8 +42,8 @@ export function retryTimeoutMs({ perMs, load1, cores }) {
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* #4589: a check timeout (rc 124) is a busy host, not a failing command: run `run(timeoutMs)`, and when it timed out on its
|
|
45
|
-
* own, once more with `retryTimeoutMs`. `left` (ms still available in a round budget) caps the retry; a retry the budget cut
|
|
46
|
-
* short is `cut` (left to CI). A repeated timeout keeps rc 124 and says it was infrastructure. Other rcs are never retried.
|
|
45
|
+
* own, once more with `retryTimeoutMs`. `left` (ms still available in a round budget) caps the retry; an exhausted budget, or a retry the budget cut
|
|
46
|
+
* short, is `cut` (left to CI); a budget with room for no longer retry leaves the first rc 124 a failure (#4620). A repeated timeout keeps rc 124 and says it was infrastructure. Other rcs are never retried.
|
|
47
47
|
* @param {(timeoutMs:number) => Promise<{rc:number, output?:string}>} run
|
|
48
48
|
* @returns {Promise<{r:{rc:number, output?:string}, retried:boolean, cut:boolean}>}
|
|
49
49
|
*/
|
|
@@ -52,8 +52,10 @@ export async function runWithTimeoutRetry(run, timeoutMs, { left = () => Infinit
|
|
|
52
52
|
if (r?.rc !== TIMEOUT_RC || !(timeoutMs > 0) || !Number.isFinite(timeoutMs)) return { r, retried: false, cut: false };
|
|
53
53
|
let want = timeoutMs;
|
|
54
54
|
try { want = retryTimeoutMs({ perMs: timeoutMs, ...load() }); } catch { /* no load reading: the minimum factor */ want = timeoutMs * 2; }
|
|
55
|
-
const
|
|
56
|
-
if (!(
|
|
55
|
+
const room = left();
|
|
56
|
+
if (!(room > 0)) return { r, retried: false, cut: true }; // the budget is spent: CI runs it
|
|
57
|
+
const t2 = Math.min(want, room);
|
|
58
|
+
if (!(t2 > timeoutMs)) return { r, retried: false, cut: false }; // #4620: no room for a longer retry; its own timeout stays a failure
|
|
57
59
|
const r2 = await run(t2);
|
|
58
60
|
const cut = r2?.rc === TIMEOUT_RC && t2 < want;
|
|
59
61
|
if (r2?.rc === TIMEOUT_RC && !cut) return { r: { ...r2, output: `${String(r2.output ?? "")}\n(infra timeout: host load, not a code failure; retried once with ${Math.round(t2 / 1000)}s)` }, retried: true, cut };
|
|
@@ -256,11 +256,26 @@ describe("check timeout is infrastructure (#4589)", () => {
|
|
|
256
256
|
assert.equal(h2.calls.length, 1);
|
|
257
257
|
assert.equal(r2.results[0].rc, 1);
|
|
258
258
|
});
|
|
259
|
-
it("a
|
|
259
|
+
it("an exhausted budget defers a command that hit its own timeout", async () => {
|
|
260
260
|
const h = harness({ cost: { slow: 9e9 } });
|
|
261
|
-
const r = await runChecks({ commands: ["slow"], budgetMs:
|
|
261
|
+
const r = await runChecks({ commands: ["slow"], budgetMs: 1000, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
|
|
262
|
+
assert.equal(h.calls.length, 1);
|
|
262
263
|
assert.deepEqual(r.deferred, ["slow"]);
|
|
263
264
|
});
|
|
265
|
+
it("default budget: a check hitting its own timeout is reported, not deferred (#4620)", async () => {
|
|
266
|
+
const plan = planPrecheck({ verify: {}, learned: ["npm test"] });
|
|
267
|
+
const h = harness({ cost: { "npm test": 9e9 } });
|
|
268
|
+
const r = await runChecks({ commands: plan.commands, budgetMs: plan.budgetMs, perCommandMs: plan.perCommandMs, now: h.now, run: h.run, load: idle });
|
|
269
|
+
assert.equal(r.results.length, 1);
|
|
270
|
+
assert.equal(r.results[0].rc, 124);
|
|
271
|
+
assert.deepEqual(r.deferred, []);
|
|
272
|
+
});
|
|
273
|
+
it("a budget with room left but no longer retry reports the timeout as a failure", async () => {
|
|
274
|
+
const h = harness({ cost: { slow: 9e9 } });
|
|
275
|
+
const r = await runChecks({ commands: ["slow"], budgetMs: 1500, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
|
|
276
|
+
assert.equal(h.calls.length, 1);
|
|
277
|
+
assert.deepEqual([r.results.map((x) => x.rc), r.deferred], [[124], []]);
|
|
278
|
+
});
|
|
264
279
|
it("runWithTimeoutRetry reports retried", async () => {
|
|
265
280
|
let n = 0;
|
|
266
281
|
const x = await runWithTimeoutRetry(async (ms) => (++n === 1 ? { rc: 124, output: "t" } : { rc: 0, output: String(ms) }), 1000, { load: idle });
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. With `post_deploy.min_age` (#4425) it also
|
|
16
16
|
* stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
|
|
17
17
|
* the default branch. Anything unresolvable fails open.
|
|
18
|
+
* Named checks (#4626): once ready, the items' `[check:<id>]` checks run from the default branch's checks file (no model
|
|
19
|
+
* call): pass closes the follow-up, fail reopens the parent. An item without a runnable check parks `awaiting-measurement`.
|
|
18
20
|
* All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
|
|
19
21
|
*/
|
|
20
22
|
import { execFile } from "node:child_process";
|
|
@@ -23,6 +25,7 @@ import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prom
|
|
|
23
25
|
import { isTrustedComment } from "../engine/phases.mjs";
|
|
24
26
|
import { APP_SLUG } from "../init-repo.mjs";
|
|
25
27
|
import { probeResultOf } from "./probe.mjs";
|
|
28
|
+
import { AWAITING_MEASUREMENT_LABEL, MONITORING_LABEL, OWNER_ASK_MARKER, itemCheckIds, measureFollowup } from "./postdeploy-check.mjs";
|
|
26
29
|
|
|
27
30
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
28
31
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -154,17 +157,38 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
154
157
|
// post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
|
|
155
158
|
export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
|
|
156
159
|
export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
|
|
160
|
+
export const AWAITING_DUE_LABEL = "awaiting-due"; // #4689
|
|
157
161
|
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
158
162
|
/** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
|
|
159
163
|
export function deferredFrom(body) {
|
|
160
164
|
const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
|
|
161
165
|
return m ? Number(m[1]) : null;
|
|
162
166
|
}
|
|
167
|
+
const DEFERRED_FROM_ALL_RE = new RegExp(DEFERRED_FROM_RE.source, "g");
|
|
168
|
+
/** #4724: every parent #P of a post-deploy rollup (one `<!-- FORGE:DEFERRED_FROM: #P -->` per `### #P` section), in order, unique; [] when none. */
|
|
169
|
+
export const deferredFromAll = (body) => [...new Set([...String(body ?? "").matchAll(DEFERRED_FROM_ALL_RE)].map((m) => Number(m[1])))];
|
|
163
170
|
const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
164
|
-
|
|
171
|
+
const checkItems = (body) => noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
|
|
172
|
+
const DECISION_ITEM_RE = /^`[^`]*`\s+[—-]+\s*decision:/; // #4689: an item only its owner can decide
|
|
173
|
+
/** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems), or a `decision:` item (#4689). */
|
|
165
174
|
export function postDeployOnly(body) {
|
|
166
|
-
const items =
|
|
167
|
-
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
|
|
175
|
+
const items = checkItems(body);
|
|
176
|
+
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t) || DECISION_ITEM_RE.test(t));
|
|
177
|
+
}
|
|
178
|
+
/** #4689: true when a checklist item is a `decision:` item (owner ask, never a lane). */
|
|
179
|
+
export const hasDecisionItem = (body) => checkItems(body).some((t) => DECISION_ITEM_RE.test(t));
|
|
180
|
+
const DUE_RE = /\bdue:(\d{4})-(\d{2})-(\d{2})\b/;
|
|
181
|
+
/** #4689: the latest `due:YYYY-MM-DD` of the post-deploy items (ms, UTC midnight), or null when none states one. */
|
|
182
|
+
export function itemDueMs(body) {
|
|
183
|
+
const ds = checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => DUE_RE.exec(t)).filter(Boolean)
|
|
184
|
+
.map((m) => Date.UTC(+m[1], +m[2] - 1, +m[3])).filter(Number.isFinite);
|
|
185
|
+
return ds.length ? Math.max(...ds) : null;
|
|
186
|
+
}
|
|
187
|
+
const ANCHOR_RE = /\bfrom:(\d{4})-(\d{2})-(\d{2})\b/;
|
|
188
|
+
/** #4724: the items' `due:` dates with their contract-time `from:` anchor (ms, UTC midnight; null when absent). */
|
|
189
|
+
export function itemDueAnchors(body) {
|
|
190
|
+
return checkItems(body).map((t) => POST_DEPLOY_ITEM_RE.exec(t)?.[1]).filter((t) => t != null).map((t) => [DUE_RE.exec(t), ANCHOR_RE.exec(t)]).filter(([d]) => d)
|
|
191
|
+
.map(([d, a]) => ({ dueMs: Date.UTC(+d[1], +d[2] - 1, +d[3]), fromMs: a ? Date.UTC(+a[1], +a[2] - 1, +a[3]) : null })).filter((x) => Number.isFinite(x.dueMs));
|
|
168
192
|
}
|
|
169
193
|
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
170
194
|
/**
|
|
@@ -240,18 +264,23 @@ const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms %
|
|
|
240
264
|
/**
|
|
241
265
|
* #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
|
|
242
266
|
* #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
|
|
243
|
-
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
267
|
+
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-due"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
244
268
|
* Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
|
|
245
269
|
*/
|
|
246
270
|
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
|
|
247
|
-
const
|
|
271
|
+
const parents = deferredFromAll(iss?.body), parent = parents[0] ?? null; // #4724: a rollup has one parent per section
|
|
272
|
+
const ps = parents.map((p) => `#${p}`).join(", ");
|
|
248
273
|
const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
|
|
249
274
|
if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
|
|
250
275
|
try {
|
|
251
276
|
const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
|
|
252
277
|
if (!def) return res("unresolved", "default branch unknown");
|
|
253
|
-
const shas =
|
|
254
|
-
|
|
278
|
+
const shas = [];
|
|
279
|
+
for (const p of parents) {
|
|
280
|
+
const found = await parentMergeShas({ parent: p, repo, io });
|
|
281
|
+
if (!found.length) return res("unresolved", `no merge SHA found for #${p}`);
|
|
282
|
+
shas.push(...found);
|
|
283
|
+
}
|
|
255
284
|
const off = [];
|
|
256
285
|
for (const sha of shas) {
|
|
257
286
|
let on;
|
|
@@ -259,7 +288,22 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
|
|
|
259
288
|
if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
|
|
260
289
|
if (!on) off.push(sha);
|
|
261
290
|
}
|
|
262
|
-
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of
|
|
291
|
+
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of ${ps} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
|
|
292
|
+
let dueMs = itemDueMs(iss.body); // #4689: an explicit due date (UTC midnight)
|
|
293
|
+
if (dueMs != null) {
|
|
294
|
+
// #4724: re-anchor a date item to when its fix actually landed on <def>: due + (landed - the contract-time anchor)
|
|
295
|
+
let landedMs = null;
|
|
296
|
+
for (const a of itemDueAnchors(iss.body)) {
|
|
297
|
+
if (a.fromMs == null) continue;
|
|
298
|
+
if (landedMs == null) { landedMs = 0; for (const sha of shas) landedMs = Math.max(landedMs, ((await landedAt({ sha, def, io, cwd })) ?? 0) * 1000); }
|
|
299
|
+
if (landedMs > a.fromMs) dueMs = Math.max(dueMs, a.dueMs + (landedMs - a.fromMs));
|
|
300
|
+
}
|
|
301
|
+
const at = now ?? io.now?.() ?? Date.now();
|
|
302
|
+
if (at < dueMs) {
|
|
303
|
+
const eligibleAt = new Date(dueMs).toISOString();
|
|
304
|
+
return res("awaiting-due", `fix of ${ps} is on ${def}; the checks are due ${eligibleAt.slice(0, 10)}`, { shas, default: def, eligibleAt });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
263
307
|
if (minAge != null) {
|
|
264
308
|
const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
|
|
265
309
|
let landed = 0;
|
|
@@ -271,16 +315,16 @@ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io,
|
|
|
271
315
|
const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
|
|
272
316
|
if (at < until) {
|
|
273
317
|
const eligibleAt = new Date(until).toISOString();
|
|
274
|
-
return res("awaiting-min-age", `fix of
|
|
318
|
+
return res("awaiting-min-age", `fix of ${ps} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
|
|
275
319
|
}
|
|
276
320
|
}
|
|
277
321
|
if (preconditionCmd) {
|
|
278
322
|
const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
|
|
279
323
|
{ cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
|
|
280
|
-
if (code === 1) return res("awaiting-precondition", `fix of
|
|
324
|
+
if (code === 1) return res("awaiting-precondition", `fix of ${ps} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
|
|
281
325
|
if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
|
|
282
326
|
}
|
|
283
|
-
return res("ready", `fix of
|
|
327
|
+
return res("ready", `fix of ${ps} is on ${def}`, { shas, default: def });
|
|
284
328
|
} catch (e) {
|
|
285
329
|
return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
|
|
286
330
|
}
|
|
@@ -298,18 +342,30 @@ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
|
|
|
298
342
|
return name;
|
|
299
343
|
})());
|
|
300
344
|
}
|
|
345
|
+
/** #4689: one typed `FORGE:OWNER_ASK` comment for the `decision:` items of a follow-up, posted once; best effort. */
|
|
346
|
+
async function ownerAsk({ iss, repo, io, dryRun }) {
|
|
347
|
+
if (dryRun || (iss.comments ?? []).some((c) => String(c?.body ?? "").includes(OWNER_ASK_MARKER) && isTrustedIssueComment(c))) return;
|
|
348
|
+
const asks = checkItems(iss.body).filter((t) => DECISION_ITEM_RE.test(t)).map((t) => `- ${sanitizeMd(t).slice(0, 300)}`);
|
|
349
|
+
try { await io.gh(["issue", "comment", String(iss.number), "-R", repo, "--body", capComment(`<!-- ${OWNER_ASK_MARKER} -->\n## Owner ask\nNo lane was started: these items need a decision from the owner.\n\n${asks.join("\n")}`)]); } catch { /* the report still shows it */ }
|
|
350
|
+
}
|
|
301
351
|
/** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
|
|
302
352
|
async function markParked({ issue, repo, io, had, gate }) {
|
|
303
|
-
const
|
|
353
|
+
const measure = gate.state === "awaiting-measurement"; // #4626
|
|
354
|
+
const due = gate.state === "awaiting-due"; // #4689
|
|
355
|
+
const label = measure ? AWAITING_MEASUREMENT_LABEL : due ? AWAITING_DUE_LABEL : AWAITING_PROMOTION_LABEL;
|
|
356
|
+
const labels = due && gate.eligibleAt ? [label, `due:${gate.eligibleAt.slice(0, 10)}`] : [label];
|
|
357
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", labels.join(",")]);
|
|
304
358
|
try { await edit(); } catch {
|
|
305
359
|
try {
|
|
306
|
-
await io.gh(["label", "create",
|
|
360
|
+
for (const l of labels) await io.gh(["label", "create", l, "-R", repo, "--force", "--color", "C5DEF5", "--description", measure ? "Post-deploy follow-up with no runnable default-branch check; never given a lane. Managed by ForgeDock." : due ? "Post-deploy follow-up parked until its due date; re-queued by the engine. Managed by ForgeDock." : "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
|
|
307
361
|
await edit();
|
|
308
362
|
} catch { /* the comment and the report still show it */ }
|
|
309
363
|
}
|
|
310
364
|
if (had) return;
|
|
311
|
-
const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
312
|
-
const
|
|
365
|
+
const what = measure ? "awaiting measurement" : gate.state === "awaiting-promotion" ? "awaiting promotion" : due ? "awaiting its due date" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
366
|
+
const tail = measure ? "Every item here is a post-deploy check, and at least one names no runnable check, so no lane was started and none will be. Name each one with `[check:<id>]` and add `<id>` to the checks file on the default branch, or measure it by hand."
|
|
367
|
+
: "Every item here is a post-deploy check, so no lane was started. The next orchestrate or triage run, or the daemon's parked sweep, checks again and releases this issue once the precondition holds.";
|
|
368
|
+
const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\n${tail}`;
|
|
313
369
|
try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
|
|
314
370
|
}
|
|
315
371
|
|
|
@@ -328,6 +384,16 @@ export function formatParkedLine(rows) {
|
|
|
328
384
|
const defs = uniq(soak.map((r) => r.prefilter.default));
|
|
329
385
|
out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
|
|
330
386
|
}
|
|
387
|
+
const due = parked.filter((r) => r.prefilter.kind === "awaiting-due"); // #4689
|
|
388
|
+
if (due.length) out.push(`due date: ${due.map((r) => `#${r.issue}${r.prefilter.eligibleAt ? ` (${r.prefilter.eligibleAt.slice(0, 10)})` : ""}`).join(" ")} (re-queued on or after the date)`);
|
|
389
|
+
const ask = parked.filter((r) => r.prefilter.kind === "owner-ask");
|
|
390
|
+
if (ask.length) out.push(`owner ask: ${ask.map((r) => `#${r.issue}`).join(" ")} (a decision or measurement only the owner can give; no lane)`);
|
|
391
|
+
const meas = parked.filter((r) => r.prefilter.kind === "awaiting-measurement"); // #4626
|
|
392
|
+
if (meas.length) out.push(`awaiting measurement: ${meas.map((r) => `#${r.issue}`).join(" ")} (no runnable default-branch check; no lane)`);
|
|
393
|
+
const failed = parked.filter((r) => r.prefilter.kind === "check-failed");
|
|
394
|
+
if (failed.length) out.push(`check failed: ${failed.map((r) => `#${r.issue}`).join(" ")} (parent reopened; waiting for a new fix)`);
|
|
395
|
+
const mon = parked.filter((r) => r.prefilter.kind === "monitoring"); // #4627
|
|
396
|
+
if (mon.length) out.push(`monitoring: ${mon.map((r) => `#${r.issue}`).join(" ")} (windowed post-deploy checks re-run on later releases)`);
|
|
331
397
|
const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
|
|
332
398
|
if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
|
|
333
399
|
return out.join("\n");
|
|
@@ -337,7 +403,7 @@ export function formatParkedLine(rows) {
|
|
|
337
403
|
* Pre-filter one issue. Never throws.
|
|
338
404
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
339
405
|
*/
|
|
340
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null }) {
|
|
406
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null, gateOnly = false }) {
|
|
341
407
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
342
408
|
try {
|
|
343
409
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
@@ -346,24 +412,43 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
346
412
|
const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
|
|
347
413
|
// #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
|
|
348
414
|
const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
|
|
415
|
+
// #4627: a follow-up under windowed monitoring belongs to `sweepDue`, never to a lane or a second measurement
|
|
416
|
+
if (names.includes(MONITORING_LABEL)) return { ...out, parked: true, kind: "monitoring", detail: "windowed post-deploy checks re-run on later releases" };
|
|
349
417
|
if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
|
|
350
418
|
if (io.projector) {
|
|
351
419
|
let st = null;
|
|
352
420
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
353
421
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
354
422
|
}
|
|
423
|
+
if (gated && hasDecisionItem(iss.body)) { // #4689: a decision item is the owner's: parked, one typed ask, no lane
|
|
424
|
+
await ownerAsk({ iss, repo, io, dryRun });
|
|
425
|
+
return { ...out, parked: true, kind: "owner-ask", parent: deferredFrom(iss.body), lane, detail: "a decision item needs the owner" };
|
|
426
|
+
}
|
|
355
427
|
if (gated) {
|
|
356
428
|
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
|
|
357
429
|
const had = names.includes(AWAITING_PROMOTION_LABEL);
|
|
358
|
-
if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
359
|
-
if (!dryRun) await markParked({ issue, repo, io, had, gate });
|
|
360
|
-
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
|
|
430
|
+
if (["awaiting-promotion", "awaiting-due", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
431
|
+
if (!dryRun) await markParked({ issue, repo, io, had: had || (gate.state === "awaiting-due" && names.includes(AWAITING_DUE_LABEL)), gate });
|
|
432
|
+
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, ...(gate.eligibleAt ? { eligibleAt: gate.eligibleAt } : {}), detail: gate.detail };
|
|
361
433
|
}
|
|
362
|
-
|
|
363
|
-
|
|
434
|
+
const stale = names.filter((l) => l === AWAITING_PROMOTION_LABEL || l === AWAITING_DUE_LABEL || /^due:/.test(l)); // #4689: released: every wait label goes
|
|
435
|
+
if (gate.state === "ready" && stale.length && !dryRun) {
|
|
436
|
+
try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", stale.join(",")]); } catch { /* best effort */ }
|
|
437
|
+
}
|
|
438
|
+
if (gate.state === "ready") {
|
|
439
|
+
const m = await measureFollowup({ iss, repo, parent: gate.parent, def: gate.default, shas: gate.shas, cwd, io, dryRun, isTrusted: isTrustedIssueComment, ...(io.spawnCheck ? { spawn: io.spawnCheck } : {}) });
|
|
440
|
+
if (m.state === "awaiting-measurement") {
|
|
441
|
+
if (!dryRun) await markParked({ issue, repo, io, had: names.includes(AWAITING_MEASUREMENT_LABEL), gate: { ...gate, state: m.state, detail: m.detail } });
|
|
442
|
+
return { ...out, parked: true, kind: m.state, parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
443
|
+
}
|
|
444
|
+
if (m.state === "monitoring") return { ...out, parked: true, kind: "monitoring", parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
445
|
+
if (m.state === "pass") return { ...out, closed: !dryRun, kind: "post-deploy-check", parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
446
|
+
if (m.state === "fail" || m.state === "recorded-fail") return { ...out, parked: true, kind: "check-failed", parent: gate.parent, lane, default: gate.default, detail: m.detail };
|
|
364
447
|
}
|
|
448
|
+
if (gateOnly) return { ...out, detail: `released (post-deploy gate: ${gate.state})` }; // #4689: the sweep never runs the already-fixed path
|
|
365
449
|
if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
|
|
366
450
|
}
|
|
451
|
+
if (gateOnly) return { ...out, detail: "not a gated follow-up" };
|
|
367
452
|
// #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
|
|
368
453
|
if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
|
|
369
454
|
&& flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt, itemDueMs, hasDecisionItem, deferredFromAll, itemDueAnchors } from "./prefilter.mjs";
|
|
6
6
|
import { writeFileSync } from "node:fs";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
@@ -397,14 +397,22 @@ describe("post-deploy precondition gate (#4389)", () => {
|
|
|
397
397
|
assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
|
|
398
398
|
});
|
|
399
399
|
|
|
400
|
-
it("an ancestor (after promotion):
|
|
400
|
+
it("an ancestor (after promotion): the promotion label is removed; with no runnable check it parks awaiting-measurement, never a lane (#4626)", async () => {
|
|
401
401
|
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
402
402
|
const r = await gated(w, 41);
|
|
403
|
-
assert.
|
|
404
|
-
assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
|
|
403
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-measurement");
|
|
404
|
+
assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"], [41, "--add-label", "awaiting-measurement"]]);
|
|
405
|
+
assert.equal(w.runner.length, 0);
|
|
405
406
|
const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
|
|
406
|
-
runOne: async () =>
|
|
407
|
-
assert.deepEqual(rows.map((x) => x.outcome), ["
|
|
407
|
+
runOne: async () => { throw new Error("no lane"); } });
|
|
408
|
+
assert.deepEqual(rows.map((x) => x.outcome), ["awaiting-measurement"]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("a follow-up under windowed monitoring is parked, never a lane or a second measurement (#4627)", async () => {
|
|
412
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "[check:smoke] post-deploy")]), labels: [{ name: "post-deploy-monitoring" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
413
|
+
const r = await gated(w, 41);
|
|
414
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "monitoring"); assert.equal(r.closed, false);
|
|
415
|
+
assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []); assert.equal(w.runner.length, 0);
|
|
408
416
|
});
|
|
409
417
|
|
|
410
418
|
it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
|
|
@@ -508,6 +516,36 @@ describe("post_deploy.min_age soak window (#4425)", () => {
|
|
|
508
516
|
assert.deepEqual(itemWindowsMs(followup([item("R1", "after 24h of traffic, then 7 days of retention"), item("R2", "only in prod")])), [7 * 24 * H * 1000, null]);
|
|
509
517
|
});
|
|
510
518
|
|
|
519
|
+
describe("post-deploy rollup with several parents (#4724)", () => {
|
|
520
|
+
const M2 = "f".repeat(40);
|
|
521
|
+
const prs = [...parentPr, { number: 91, title: "fix(y): other (#50)", body: "", mergeCommit: { oid: M2 } }];
|
|
522
|
+
const rollup = (items) => `## Acceptance Criteria\n\n### #40\n<!-- FORGE:DEFERRED_FROM: #40 -->\n${items[0]}\n### #50\n<!-- FORGE:DEFERRED_FROM: #50 -->\n${items[1] ?? items[0]}\n`;
|
|
523
|
+
it("deferredFromAll lists every section parent once, in order", () => {
|
|
524
|
+
assert.deepEqual(deferredFromAll(rollup([item("R1", "x")])), [40, 50]);
|
|
525
|
+
assert.deepEqual(deferredFromAll("no marker"), []);
|
|
526
|
+
assert.equal(deferredFrom(rollup([item("R1", "x")])), 40);
|
|
527
|
+
});
|
|
528
|
+
it("the rollup waits while ANY parent is off the default branch, and names them all", async () => {
|
|
529
|
+
const w = soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 1 } });
|
|
530
|
+
const g = await gate(w, rollup([item("R1", "x")]));
|
|
531
|
+
assert.equal(g.state, "awaiting-promotion"); assert.match(g.detail, /#40, #50/); assert.deepEqual(g.shas, [MERGE, M2]);
|
|
532
|
+
});
|
|
533
|
+
it("is ready once every parent is on the default branch; a parent without a merge SHA is unresolved", async () => {
|
|
534
|
+
assert.equal((await gate(soakWorld({ mergedPrs: prs, ancestry: { [MERGE]: 0, [M2]: 0 } }), rollup([item("R1", "x")]))).state, "ready");
|
|
535
|
+
const g = await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), rollup([item("R1", "x")]));
|
|
536
|
+
assert.equal(g.state, "unresolved"); assert.match(g.detail, /#50/);
|
|
537
|
+
});
|
|
538
|
+
it("a date item is re-anchored to when its fix landed: due + (landed - from)", async () => {
|
|
539
|
+
const it1 = item("R1", "[bucket:date] due:2027-02-01 from:2027-01-01 later");
|
|
540
|
+
assert.deepEqual(itemDueAnchors(followup([it1])), [{ dueMs: Date.UTC(2027, 1, 1), fromMs: Date.UTC(2027, 0, 1) }]);
|
|
541
|
+
const w = soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
542
|
+
const g = await gate(w, followup([it1]), { now: Date.UTC(2027, 1, 10) }); // past the contract-time date, before the shifted one
|
|
543
|
+
assert.equal(g.state, "awaiting-due");
|
|
544
|
+
assert.equal(g.eligibleAt, new Date(Date.UTC(2027, 1, 1) + LANDED * 1000 - Date.UTC(2027, 0, 1)).toISOString());
|
|
545
|
+
assert.equal((await gate(soakWorld({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } }), followup([it1]), { now: Date.UTC(2027, 2, 1) })).state, "ready");
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
511
549
|
it("landedAt: the promotion merge time, or the SHA's own time when it is on the mainline", async () => {
|
|
512
550
|
const w = soakWorld();
|
|
513
551
|
assert.equal(await landedAt({ sha: MERGE, def: "main", io: w.io, cwd: "/repo" }), LANDED);
|
|
@@ -594,3 +632,45 @@ describe("#4467: isTrustedIssueComment", () => {
|
|
|
594
632
|
assert.equal(isTrustedIssueComment(null), false);
|
|
595
633
|
});
|
|
596
634
|
});
|
|
635
|
+
|
|
636
|
+
describe("due-date and decision follow-ups (#4689)", () => {
|
|
637
|
+
const MERGE = "a".repeat(40);
|
|
638
|
+
const dueItem = (date) => `- [ ] \`R2: the 7-day window shows a health row\` — post-deploy: due:${date} only checkable after the window`;
|
|
639
|
+
const decision = "- [ ] `R3: pick the retention` — decision: owner chooses 30 or 90 days";
|
|
640
|
+
const body = (items) => `## Problem\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
|
|
641
|
+
const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
|
|
642
|
+
const at = (iso) => Date.parse(iso);
|
|
643
|
+
|
|
644
|
+
it("parses the due token (latest wins) and decision items; a decision-only body counts as verification-only", () => {
|
|
645
|
+
assert.equal(itemDueMs(body([dueItem("2026-11-03"), dueItem("2026-11-10")])), Date.UTC(2026, 10, 10));
|
|
646
|
+
assert.equal(itemDueMs(body(["- [ ] `R2: x` — post-deploy: no date"])), null);
|
|
647
|
+
assert.ok(postDeployOnly(body([decision]))); assert.ok(hasDecisionItem(body([decision])));
|
|
648
|
+
assert.ok(!hasDecisionItem(body([dueItem("2026-11-03")])));
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it("before the due date the follow-up parks awaiting-due with a due:<date> label, no lane; after it, the labels are removed", async () => {
|
|
652
|
+
const w = world({ issues: { 41: { body: body([dueItem("2026-11-03")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
653
|
+
w.io.now = () => at("2026-11-01T00:00:00Z");
|
|
654
|
+
const r = await pf(w, 41, { defaultBranch: "main" });
|
|
655
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-due"); assert.equal(r.eligibleAt, "2026-11-03T00:00:00.000Z");
|
|
656
|
+
assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-due,due:2026-11-03"]]);
|
|
657
|
+
assert.equal(w.runner.length, 0);
|
|
658
|
+
assert.match(formatParkedLine([{ issue: 41, prefilter: r }]), /^due date: #41 \(2026-11-03\)/);
|
|
659
|
+
const w2 = world({ issues: { 41: { body: body([dueItem("2026-11-03")]), labels: [{ name: "awaiting-due" }, { name: "due:2026-11-03" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
660
|
+
w2.io.now = () => at("2026-11-03T00:00:01Z");
|
|
661
|
+
const r2 = await pf(w2, 41, { defaultBranch: "main", gateOnly: true });
|
|
662
|
+
assert.equal(r2.kind, "awaiting-measurement"); // released from the date; with no check on main it waits on its owner, never a lane
|
|
663
|
+
assert.deepEqual(w2.edits[0], [41, "--remove-label", "awaiting-due,due:2026-11-03"]);
|
|
664
|
+
assert.ok(w2.comments.some(([, b]) => /FORGE:OWNER_ASK/.test(b)));
|
|
665
|
+
assert.equal(w2.runner.length, 0);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("a decision item is parked as one typed owner ask (posted once), never a lane, never needs-human", async () => {
|
|
669
|
+
const w = world({ issues: { 42: { body: body([decision]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
670
|
+
const r = await pf(w, 42, { defaultBranch: "main" });
|
|
671
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "owner-ask"); assert.equal(w.runner.length, 0);
|
|
672
|
+
assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:OWNER_ASK/);
|
|
673
|
+
assert.match(formatParkedLine([{ issue: 42, prefilter: r }]), /^owner ask: #42/);
|
|
674
|
+
assert.deepEqual(w.edits, []);
|
|
675
|
+
});
|
|
676
|
+
});
|
package/bin/lean/probe.mjs
CHANGED
|
@@ -244,14 +244,14 @@ export async function probeIssue({ issue, repo, cwd = process.cwd(), io, default
|
|
|
244
244
|
try { defaultSha = String(await io.git(["rev-parse", `origin/${defaultBranch}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
|
|
245
245
|
const r = await runProbe(d.probe, { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
|
|
246
246
|
const detail = `exit ${r.exit ?? "timeout"} in ${r.ms}ms`;
|
|
247
|
-
if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, output: r.output });
|
|
247
|
+
if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, defaultBranch, output: r.output });
|
|
248
248
|
const body = renderProbeComment({ probe: d.probe, fix, defaultBranch, defaultSha, r });
|
|
249
249
|
let note = "";
|
|
250
250
|
if (r.result === "fail") {
|
|
251
251
|
try { await io.gh(["issue", "reopen", String(issue), "-R", repo]); } catch (e) { note = `; reopen failed: ${String(e?.message ?? e).slice(0, 120)}`; }
|
|
252
252
|
}
|
|
253
253
|
await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", body]);
|
|
254
|
-
return row(r.result, detail + note, { fix });
|
|
254
|
+
return row(r.result, detail + note, { fix, defaultBranch });
|
|
255
255
|
}
|
|
256
256
|
|
|
257
257
|
/** The default branch: forge.yaml `branches.default`, else GitHub's. */
|
|
@@ -267,7 +267,7 @@ export async function resolveDefaultBranch({ repo, io, cwd = process.cwd() }) {
|
|
|
267
267
|
* Probe `issues`, or (none given) the issues closed within `sweepDays` that mention `forge-probe`, capped at `limit`.
|
|
268
268
|
* Sequential: probes hit production. Never throws per issue: an error is an `error` row.
|
|
269
269
|
*/
|
|
270
|
-
export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn } = {}) {
|
|
270
|
+
export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn, onResult = null } = {}) {
|
|
271
271
|
if (!config.enabled && !issues?.length) return [];
|
|
272
272
|
const defaultBranch = await resolveDefaultBranch({ repo, io, cwd });
|
|
273
273
|
if (!defaultBranch) throw new Error("no default branch");
|
|
@@ -284,6 +284,7 @@ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null
|
|
|
284
284
|
try { rows.push(await probeIssue({ issue, repo, cwd, io, defaultBranch, dryRun, config, env, spawn })); } catch (e) {
|
|
285
285
|
rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
|
|
286
286
|
}
|
|
287
|
+
try { if (!dryRun) await onResult?.(rows[rows.length - 1]); } catch { /* an event sink never fails the sweep (#4630) */ }
|
|
287
288
|
}
|
|
288
289
|
return rows;
|
|
289
290
|
}
|
package/bin/lean/probe.test.mjs
CHANGED
|
@@ -125,7 +125,7 @@ describe("probe (#4399)", () => {
|
|
|
125
125
|
const w = world({ comments: [], prs: [{ number: 3, title: "x", body: "mentions #7 only", mergeCommit: { oid: "1111111" }, mergedAt: "2026-10-02" },
|
|
126
126
|
{ number: 4, title: "fix", body: "Closes #7", mergeCommit: { oid: "2222222" }, mergedAt: "2026-10-01" }] });
|
|
127
127
|
const r = await run(w);
|
|
128
|
-
assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222");
|
|
128
|
+
assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222"); assert.ok(r.defaultBranch);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
it("pass: records FORGE:PROBE with the result JSON, no reopen", async () => {
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -63,10 +63,19 @@ export function duplicateExportsPrompt(findings, mode = "local") {
|
|
|
63
63
|
return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** #4596: new keys the lane copied from the source-locale catalog into another locale's catalog. */
|
|
67
|
+
export function i18nCopiesPrompt(findings, mode = "local") {
|
|
68
|
+
const rows = findings.slice(0, 30).map((d) => `- ${d.path}: ${d.key} = ${JSON.stringify(d.value)} (same as ${d.source})`).join("\n");
|
|
69
|
+
return `This change adds keys to locale catalogs as English copies of the source locale:\n${rows}\n\nTranslate each into that catalog's language (keep placeholders), and add any translation-status entry the repo tracks as a minimal diff: insert in place, never re-sort or reformat the file. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
/** #4591: lane-added migrations the repo's generator did not produce. */
|
|
67
73
|
export function migrationGeneratorPrompt(problems, mode = "local") {
|
|
68
74
|
const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
|
|
69
|
-
|
|
75
|
+
const stale = problems.some((p) => /not above the base max when|is not after/.test(p))
|
|
76
|
+
? ` Entries reported with a stale \`when\` are stamped older than the base's newest migration, so a newest-only migrator (drizzle \`migrate()\`) skips them on a persistent database: re-stamp each one (a \`when\` and name above the base journal's max, same SQL, snapshot regenerated by the generator) and keep \`when\` increasing in the journal.`
|
|
77
|
+
: "";
|
|
78
|
+
return `These migration problems were found (hand-named files the repo's migration generator did not produce, or journal timestamps older than the base's):\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand.${stale} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
|
|
@@ -148,12 +157,13 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
|
148
157
|
If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
|
|
149
158
|
|
|
150
159
|
2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
|
|
151
|
-
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<
|
|
160
|
+
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<defer, post-deploy>","bucket":"<defer, post-deploy: post-deploy|verify-now|date:YYYY-MM-DD|owner:<action>|new-issue>","check":"<post-deploy: id in .forge/post-deploy-checks.json>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
|
|
152
161
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
153
162
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
154
163
|
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
|
|
155
164
|
"decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
|
|
156
|
-
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
|
|
165
|
+
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. Every defer or post-deploy row has one "bucket": post-deploy (needs a release), verify-now (parent already on the default branch), date:YYYY-MM-DD (absolute, never a relative wait), owner:<action> (credentials, spend, settings; disposition defer, never post-deploy) or new-issue (future work). One sentence is one row. An outcome criterion this change can ship as a test, metric or alert is "deliver". A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
|
|
166
|
+
A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
|
|
157
167
|
A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
|
|
158
168
|
Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
|
|
159
169
|
A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
|
|
@@ -165,7 +175,7 @@ ${BLOCK_RULES}`;
|
|
|
165
175
|
* #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
|
|
166
176
|
* `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
|
|
167
177
|
*/
|
|
168
|
-
export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
|
|
178
|
+
export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result. A [type:e2e] requirement is delivered only when its validate command is a real end-to-end check, not a unit test with stubbed I/O.
|
|
169
179
|
2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
|
|
170
180
|
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
171
181
|
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
@@ -178,12 +188,15 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
|
|
|
178
188
|
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
179
189
|
|
|
180
190
|
The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
|
|
181
|
-
- {"kind":"add-deliverable","path":"<file>","why":""}: a file
|
|
182
|
-
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}:
|
|
183
|
-
- {"kind":"defer|post-deploy","requirement":"R#","why":""}:
|
|
184
|
-
- {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded
|
|
191
|
+
- {"kind":"add-deliverable","path":"<file>","why":""}: a file not listed.
|
|
192
|
+
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: a missed issue requirement.
|
|
193
|
+
- {"kind":"defer|post-deploy","requirement":"R#","why":"","check":"<id>"}: separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; unknown kinds are rejected. One requirement id per row (R2, R3 as two rows).
|
|
194
|
+
- {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded.
|
|
195
|
+
- {"kind":"decision","decision":{"choice":"","anchors":["<base path>"],"supersedes":"<id>"},"why":""}: supersedes changes an H-row.
|
|
196
|
+
New keys: translate, never copy English; status entries in place.
|
|
185
197
|
Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
|
|
186
198
|
Never name or number migration files by hand; use the generator.
|
|
199
|
+
A [type:e2e] requirement needs a validate that runs a real e2e check (repo e2e set, Playwright, live stack), not a stubbed unit test; else post-deploy.
|
|
187
200
|
A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
|
|
188
201
|
If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
|
|
189
202
|
|
|
@@ -364,7 +377,8 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
|
|
|
364
377
|
You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
|
|
365
378
|
- issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
|
|
366
379
|
- amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
|
|
367
|
-
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
380
|
+
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
381
|
+
- post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
|
|
368
382
|
- scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
|
|
369
383
|
- preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
|
|
370
384
|
- regressions: concrete breakages only, each with path:line.
|
package/bin/lean/resume.test.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n$
|
|
|
11
11
|
const CONTRACT = {
|
|
12
12
|
requirements: [
|
|
13
13
|
{ id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
|
|
14
|
-
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
|
|
14
|
+
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod", bucket: "post-deploy" },
|
|
15
15
|
{ id: "R3", quote: "Log the retry count.", disposition: "deliver" },
|
|
16
16
|
],
|
|
17
17
|
validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
|