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.
Files changed (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -14,12 +14,13 @@
14
14
  * host whose memory is used by something else. Fail-open: an unreadable registry or a lock timeout grants.
15
15
  *
16
16
  * Builder-run commands are gated by per-run PATH shims (`writeHeavyShims`) that call this file as a CLI:
17
- * `heavy.mjs run --name <n> -- <abs-real-bin> args...` (nested calls pass through via FORGE_HEAVY_HELD).
17
+ * `heavy.mjs run --name <n> -- <abs-real-bin> args...` (nested calls pass through via FORGE_HEAVY_HELD). A wrapper that
18
+ * re-resolves the name back to the shim is cut off by FORGE_HEAVY_CHAIN (`name:cmd|...`, see `runWrapped`, #4680).
18
19
  */
19
20
  import { spawn } from "node:child_process";
20
21
  import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, appendFileSync, writeFileSync, chmodSync } from "node:fs";
21
22
  import { cpus, freemem } from "node:os";
22
- import { join } from "node:path";
23
+ import { dirname, join } from "node:path";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
  import { writeAtomic } from "./batch.mjs";
25
26
  import { schedDir, pidAlive, DEFAULT_PRIORITY } from "./sched.mjs";
@@ -249,15 +250,34 @@ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
249
250
 
250
251
  /** Shim script for one command: exec node heavy.mjs run --name <n> -- <real> "$@". */
251
252
  export function heavyShimScript(name, real, { node = process.execPath, script = fileURLToPath(import.meta.url) } = {}) {
252
- return `${SHIM_HEADER}exec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
253
+ return `${SHIM_HEADER}export FORGE_HEAVY_SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
253
254
  }
254
255
 
256
+ /** True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). */
257
+ export function isPassThroughWrapper(text, name) {
258
+ const t = String(text ?? "");
259
+ return t.startsWith("#!") && t.includes(name) && /\b(command\s+-v|which|type\s+-p)\b/.test(t);
260
+ }
261
+ const head = (p) => { try { return readFileSync(p, "utf8").slice(0, 4096); } catch { return ""; } };
262
+
255
263
  /**
256
264
  * Write 0755 shims into `binDir` for every HEAVY_BINS name found on `path` (binDir excluded, real path resolved now, so
257
- * a shim never calls itself). Returns the names written.
265
+ * a shim never calls itself). A target that is a pass-through wrapper is skipped for the next match past its dir, or the
266
+ * shim is skipped with a `warn` line when none is left (#4680). Returns the names written.
258
267
  */
259
- export async function writeHeavyShims(binDir, { path = process.env.PATH ?? "", find = which, node, script, mkdirp = (d) => mkdirSync(d, { recursive: true }), writeShim = (p, t) => { writeFileSync(p, t); chmodSync(p, 0o755); } } = {}) {
260
- const found = HEAVY_BINS.map((n) => [n, find(n, path, [binDir])]).filter(([, r]) => r);
268
+ export async function writeHeavyShims(binDir, { path = process.env.PATH ?? "", find = which, read = head, warn = (m) => process.stderr.write(`${m}\n`), node, script, mkdirp = (d) => mkdirSync(d, { recursive: true }), writeShim = (p, t) => { writeFileSync(p, t); chmodSync(p, 0o755); } } = {}) {
269
+ const found = [];
270
+ for (const n of HEAVY_BINS) {
271
+ const skip = [binDir];
272
+ let real = find(n, path, skip);
273
+ while (real && isPassThroughWrapper(read(real), n)) {
274
+ skip.push(dirname(real));
275
+ const next = find(n, path, skip);
276
+ if (!next) warn(`forgedock heavy: ${n} at ${real} is a PATH pass-through wrapper with no real binary behind it; shim skipped`);
277
+ real = next;
278
+ }
279
+ if (real) found.push([n, real]);
280
+ }
261
281
  if (!found.length) return [];
262
282
  await mkdirp(binDir);
263
283
  for (const [n, real] of found) await writeShim(join(binDir, n), heavyShimScript(n, real, { ...(node ? { node } : {}), ...(script ? { script } : {}) }));
@@ -277,17 +297,43 @@ export function sumWaits(text, waits = {}) {
277
297
  return { waits, queueMax };
278
298
  }
279
299
 
300
+ export const SHIM_LOOP_MAX = 4;
301
+ /**
302
+ * Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
303
+ * pass-through wrapper means it sent the call back to the shim; a repeat of a real binary is a legitimate nested call
304
+ * (`npm run` inside an npm script) and passes through unchanged, unrecorded. For a wrapper, resolve the next PATH match past the shim dir and every chain dir; none, or more
305
+ * than SHIM_LOOP_MAX entries, is a typed `env.shim_loop` error and nothing is spawned. Returns {cmd, env} or {error}.
306
+ */
307
+ export function guardChain(name, cmd, env, read = head) {
308
+ const chain = String(env.FORGE_HEAVY_CHAIN ?? "").split("|").filter(Boolean);
309
+ const dirs = chain.map((e) => dirname(e.slice(e.indexOf(":") + 1)));
310
+ const loop = (why) => ({ error: `env.shim_loop: ${name} ${why} (PATH dirs: ${[env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean).join(", ") || "none"})` });
311
+ const repeat = chain.includes(`${name}:${cmd}`);
312
+ if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env };
313
+ if (chain.length >= SHIM_LOOP_MAX) return loop(`re-entered the heavy shim more than ${SHIM_LOOP_MAX} times`);
314
+ if (repeat) {
315
+ const next = which(name, env.PATH ?? "", [env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean));
316
+ if (!next) return loop(`re-entered via ${cmd} and no other ${name} is on PATH`);
317
+ cmd = next;
318
+ }
319
+ return { cmd, env: { ...env, FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
320
+ }
321
+
280
322
  /**
281
323
  * CLI body: `run --name <n> -- <real> args...`. Pass-through when FORGE_HEAVY_HELD is set or the step is llm; else
282
324
  * acquire, run with FORGE_HEAVY_HELD=1 and FORGE_HOST_PARALLELISM from live holders, release, propagate the exit.
283
325
  */
284
- export async function runWrapped(argv, { env = process.env, open = openHeavy, run = spawnInherit, host } = {}) {
326
+ export async function runWrapped(argv, { env: env0 = process.env, open = openHeavy, run = spawnInherit, host } = {}) {
327
+ let env = env0;
285
328
  const sep = argv.indexOf("--");
286
329
  if (argv[0] !== "run" || sep < 0 || !argv[sep + 1]) throw new Error("usage: heavy.mjs run --name <name> -- <command> [args...]");
287
330
  const ni = argv.indexOf("--name");
288
331
  const name = ni >= 0 && ni < sep ? argv[ni + 1] : argv[sep + 1];
289
- const [cmd, ...args] = argv.slice(sep + 1);
332
+ let [cmd, ...args] = argv.slice(sep + 1);
290
333
  const c = heavyConfig({}, env);
334
+ const g0 = guardChain(name, cmd, env);
335
+ if (g0.error) { process.stderr.write(`${g0.error}\n`); return { code: 126, signal: null }; }
336
+ cmd = g0.cmd; env = g0.env;
291
337
  if (env.FORGE_HEAVY_HELD || env.FORGE_HEAVY_DISABLED === "1" || classifyStep(name, args) === "llm") return run(cmd, args, env);
292
338
  const hostP = host ?? defaultHost();
293
339
  const h = open(env.FORGEDOCK_SCHED_DIR || schedDir(env), { env, host: hostP });
@@ -2,10 +2,11 @@
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 { spawnSync } from "node:child_process";
5
6
  import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
6
7
  import { join } from "node:path";
7
8
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
+ import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
10
  import { checkAdmission, ADMISSION_DEFAULTS } from "./admission.mjs";
10
11
  import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
11
12
  import { readHeavy } from "../engine-cli.mjs";
@@ -235,3 +236,75 @@ describe("heavy shims and the run wrapper (#4169)", () => {
235
236
  ["ALERTS", 'deferred-to-ci: 40% (4/10 lanes, threshold 30%), e.g. "npx tsc"']);
236
237
  });
237
238
  });
239
+
240
+ describe("shim re-entry through a pass-through wrapper (#4680)", () => {
241
+ it("shim with pass-through wrapper runs once", async () => {
242
+ const d = tmpDir("heavy-wrap-"), wrap = join(d, "wrap"), real = join(d, "real"), bin = join(d, "bin"), log = join(d, "log");
243
+ mkdirSync(wrap, { recursive: true }); mkdirSync(real, { recursive: true });
244
+ writeFileSync(join(wrap, "docker"), `#!/bin/sh\necho w >> ${log}\nexec "$(PATH="\${PATH#${wrap}:}" command -v docker)" "$@"\n`);
245
+ writeFileSync(join(real, "docker"), `#!/bin/sh\necho r >> ${log}\necho "Docker version 1"\n`);
246
+ for (const dir of [wrap, real]) chmodSync(join(dir, "docker"), 0o755);
247
+ const path = `${wrap}:${bin}:${real}`;
248
+ const warnings = [];
249
+ assert.deepEqual(await writeHeavyShims(bin, { path, warn: (m) => warnings.push(m) }), ["docker"]);
250
+ assert.match(readFileSync(join(bin, "docker"), "utf8"), new RegExp(`-- '${real}/docker' "\\$@"`), "wrapper skipped at write time, real binary baked");
251
+ assert.deepEqual(warnings, []);
252
+ // Force the runtime guard: bake the wrapper itself, as a shim written before the wrapper appeared would.
253
+ writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker")));
254
+ chmodSync(join(bin, "docker"), 0o755);
255
+ const r = spawnSync(join(bin, "docker"), ["--version"], { env: { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" }, encoding: "utf8", timeout: 20000 });
256
+ assert.equal(r.status, 0, r.stderr);
257
+ assert.match(r.stdout, /Docker version 1/);
258
+ assert.equal(readFileSync(log, "utf8"), "w\nr\n", "wrapper and real binary each ran once (two shim node processes at most)");
259
+ });
260
+
261
+ it("a name+cmd seen twice resolves past the shim and chain dirs; no match or depth above the cap is env.shim_loop", () => {
262
+ const d = tmpDir("heavy-chain-"), a = join(d, "a"), b = join(d, "b"), s = join(d, "s");
263
+ for (const x of [a, b, s]) { mkdirSync(x, { recursive: true }); writeFileSync(join(x, "tsc"), '#!/bin/sh\nexec "$(command -v tsc)" "$@"\n'); chmodSync(join(x, "tsc"), 0o755); }
264
+ const env = { PATH: [s, a, b].join(":"), FORGE_HEAVY_SHIM_DIR: s };
265
+ const first = guardChain("tsc", join(a, "tsc"), env);
266
+ assert.equal(first.cmd, join(a, "tsc")); assert.equal(first.env.FORGE_HEAVY_CHAIN, `tsc:${join(a, "tsc")}`);
267
+ const again = guardChain("tsc", join(a, "tsc"), first.env);
268
+ assert.equal(again.cmd, join(b, "tsc"));
269
+ assert.equal(again.env.FORGE_HEAVY_CHAIN, `tsc:${join(a, "tsc")}|tsc:${join(b, "tsc")}`);
270
+ const none = guardChain("tsc", join(b, "tsc"), { ...again.env, PATH: [s, a, b].join(":") });
271
+ assert.match(none.error, /^env\.shim_loop: tsc .*no other tsc/);
272
+ const deep = guardChain("tsc", "/x/tsc", { ...env, FORGE_HEAVY_CHAIN: Array.from({ length: SHIM_LOOP_MAX }, (_, i) => `tsc:/d${i}/tsc`).join("|") });
273
+ assert.match(deep.error, /^env\.shim_loop: tsc re-entered .*more than 4 times.*\/d0/);
274
+ });
275
+
276
+ it("a nested call to the same real binary (not a wrapper) passes through at any depth, unrecorded", async () => {
277
+ const real = "/home/u/.nvm/bin/npm", spawned = [];
278
+ const run = async (cmd, a, e) => { spawned.push([cmd, e.FORGE_HEAVY_CHAIN]); return { code: 0, signal: null }; };
279
+ const chain = Array.from({ length: SHIM_LOOP_MAX - 1 }, (_, i) => `npm:/d${i}/npm`).concat(`npm:${real}`).join("|");
280
+ for (const FORGE_HEAVY_CHAIN of [`npm:${real}`, chain]) {
281
+ const env = { PATH: "/shim:/home/u/.nvm/bin", FORGE_HEAVY_CHAIN, FORGE_HEAVY_HELD: "1" };
282
+ assert.equal((await runWrapped(["run", "--name", "npm", "--", real, "run", "build"], { env, run })).code, 0);
283
+ }
284
+ assert.equal(chain.split("|").length, SHIM_LOOP_MAX);
285
+ assert.deepEqual(spawned, [[real, `npm:${real}`], [real, chain]]);
286
+ });
287
+
288
+ it("runWrapped refuses to spawn on a loop and records the chain otherwise", async () => {
289
+ const spawned = [];
290
+ const run = async (cmd, a, e) => { spawned.push([cmd, e.FORGE_HEAVY_CHAIN]); return { code: 0, signal: null }; };
291
+ const env = { FORGE_HEAVY_HELD: "1", FORGE_HEAVY_CHAIN: Array.from({ length: SHIM_LOOP_MAX }, (_, i) => `x:/d${i}/x`).join("|") };
292
+ assert.equal((await runWrapped(["run", "--name", "x", "--", "/r/x"], { env, run })).code, 126);
293
+ assert.deepEqual(spawned, []);
294
+ await runWrapped(["run", "--name", "x", "--", "/r/x"], { env: { FORGE_HEAVY_HELD: "1" }, run });
295
+ assert.deepEqual(spawned, [["/r/x", "x:/r/x"]]);
296
+ });
297
+
298
+ it("wrapper detection: PATH-lookup shell scripts only; a wrapper with nothing behind it is skipped with a warning", async () => {
299
+ assert.ok(isPassThroughWrapper('#!/bin/sh\nexec "$(command -v docker)" "$@"', "docker"));
300
+ assert.ok(!isPassThroughWrapper("#!/bin/sh\necho hi\n", "docker"));
301
+ assert.ok(!isPassThroughWrapper("\x7fELF command -v docker", "docker"));
302
+ const d = tmpDir("heavy-skip-"), wrap = join(d, "wrap"), bin = join(d, "bin");
303
+ mkdirSync(wrap, { recursive: true });
304
+ writeFileSync(join(wrap, "docker"), '#!/bin/sh\nexec "$(command -v docker)" "$@"\n'); chmodSync(join(wrap, "docker"), 0o755);
305
+ const warnings = [];
306
+ assert.deepEqual(await writeHeavyShims(bin, { path: `${bin}:${wrap}`, warn: (m) => warnings.push(m) }), []);
307
+ assert.equal(warnings.length, 1); assert.match(warnings[0], /docker .*wrapper/);
308
+ assert.ok(!existsSync(join(bin, "docker")));
309
+ });
310
+ });
@@ -3,6 +3,6 @@
3
3
  * or status 143/137 and no timer of ours fired (our own kills throw CLI_TIMEOUT / CLI_IDLE / CLI_CONTEXT / CLI_TURNS).
4
4
  */
5
5
  export function isExternalKill(e) {
6
- if (e?.code !== "CLI_BACKEND_FAILED") return false;
6
+ if (e?.code !== "CLI_BACKEND_FAILED") return false; // #4690: a scope-limit stop is CLI_LANE_LIMIT, never a host kill
7
7
  return e.signal === "SIGTERM" || e.signal === "SIGKILL" || e.status === 143 || e.status === 137;
8
8
  }
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * English copies in locale catalogs (#4596). A lane adds a key to the source-locale (en) JSON catalog and copies the
5
+ * English text into the other locales; the repo's own i18n check rejects it, but only on CI. Deterministic (git, no LLM):
6
+ * for each changed non-en JSON catalog whose en counterpart exists, the keys the lane added whose string value equals the
7
+ * en value. Counterpart: the path with its locale segment (a directory or the file stem) replaced by `en`.
8
+ */
9
+ const LOCALE_RE = /^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/;
10
+ const MAX_FINDINGS = 200;
11
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
12
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
13
+
14
+ /** The en counterpart path of a non-en catalog path, or null when no segment looks like a locale code. */
15
+ export function sourcePath(path) {
16
+ const segs = String(path).split("/");
17
+ for (let i = segs.length - 1; i >= 0; i--) {
18
+ const last = i === segs.length - 1;
19
+ const m = last ? /^(.+)\.json$/.exec(segs[i]) : null;
20
+ const name = last ? (m ? m[1] : "") : segs[i];
21
+ if (!LOCALE_RE.test(name) || name === "en") continue;
22
+ const copy = [...segs];
23
+ copy[i] = last ? "en.json" : "en";
24
+ return copy.join("/");
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** Flattened string leaves of a JSON catalog as Map(dotted key -> value); an empty map on invalid JSON. */
30
+ export function flatten(text) {
31
+ const res = new Map();
32
+ let doc;
33
+ try { doc = JSON.parse(String(text ?? "")); } catch { return res; }
34
+ const walk = (v, key) => {
35
+ if (typeof v === "string") res.set(key, v);
36
+ else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, key ? `${key}.${k}` : k);
37
+ };
38
+ walk(doc, "");
39
+ return res;
40
+ }
41
+
42
+ /** A value worth translating: three letters in a row once placeholders are removed (not "OK", "{{n}}", "12:00"). */
43
+ const translatable = (v) => /\p{L}{3}/u.test(v.replace(/\{\{?[^}]*\}\}?/g, ""));
44
+
45
+ /** @returns {Promise<Array<{path, key, value, source}>>}; never throws ([] on any failure). */
46
+ export async function englishCopies({ git, base }) {
47
+ try {
48
+ const g = out(git);
49
+ const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=AM", `${base}...HEAD`])).filter((f) => f.endsWith(".json"));
50
+ const res = [];
51
+ for (const f of changed) {
52
+ const src = sourcePath(f);
53
+ if (!src) continue;
54
+ const en = flatten(await g(["show", `HEAD:${src}`]));
55
+ if (!en.size) continue;
56
+ const was = flatten(await g(["show", `${base}:${f}`]));
57
+ for (const [key, value] of flatten(await g(["show", `HEAD:${f}`]))) {
58
+ if (was.has(key) || en.get(key) !== value || !translatable(value)) continue;
59
+ res.push({ path: f, key, value, source: src });
60
+ if (res.length >= MAX_FINDINGS) return res;
61
+ }
62
+ }
63
+ return res;
64
+ } catch { return []; }
65
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { englishCopies, sourcePath, flatten } from "./i18n.mjs";
10
+
11
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
12
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), JSON.stringify(t)); } };
13
+ function repo(base, lane) {
14
+ const d = tmpDir("i18n-");
15
+ sh(d, "init", "-q", "-b", "main");
16
+ put(d, base); sh(d, "add", "-A"); sh(d, "commit", "-qm", "base");
17
+ sh(d, "checkout", "-q", "-b", "lane");
18
+ put(d, lane); sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
19
+ return d;
20
+ }
21
+ const copies = (d) => englishCopies({ git: async (a) => sh(d, ...a), base: "main" });
22
+
23
+ describe("i18n english copies (#4596)", () => {
24
+ it("sourcePath maps a locale file or directory to en", () => {
25
+ assert.equal(sourcePath("messages/ta.json"), "messages/en.json");
26
+ assert.equal(sourcePath("locales/kn/common.json"), "locales/en/common.json");
27
+ assert.equal(sourcePath("messages/en.json"), null);
28
+ assert.equal(sourcePath("package.json"), null);
29
+ });
30
+ it("flatten reads nested string leaves", () => {
31
+ assert.deepEqual([...flatten('{"a":{"b":"x"},"n":1}')], [["a.b", "x"]]);
32
+ assert.equal(flatten("not json").size, 0);
33
+ });
34
+ it("flags added keys copied from en; ignores translated, pre-existing, short and placeholder values", async () => {
35
+ const d = repo(
36
+ { "messages/en.json": { old: "Old text", v: { a: "Calendar view" } }, "messages/ta.json": { old: "Old text" } },
37
+ { "messages/en.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "Week view" } },
38
+ "messages/ta.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "வாரக் காட்சி" } } });
39
+ const r = await copies(d);
40
+ assert.deepEqual(r.map((x) => `${x.path}:${x.key}`), ["messages/ta.json:v.a"]);
41
+ });
42
+ it("is empty without a source catalog and never throws", async () => {
43
+ const d = repo({ "messages/ta.json": { a: "Hello there" } }, { "messages/ta.json": { a: "Hello there", b: "Hello there" } });
44
+ assert.deepEqual(await copies(d), []);
45
+ assert.deepEqual(await englishCopies({ git: async () => { throw new Error("x"); }, base: "main" }), []);
46
+ });
47
+ });
@@ -0,0 +1,276 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Milestone integration runs (forge#4667). No LLM anywhere. Under "fast lane gate, heavy ship gate" the whole e2e suite
5
+ * only runs at the ship PR; this runs it (report-only) on every active milestone branch on a schedule or every N lane
6
+ * merges, so integration failures surface while the milestone is still open.
7
+ *
8
+ * - Config: forge.yaml `ci:` / `milestone_integration:` block (the parser flattens its keys under `ci`); inert without `workflow`.
9
+ * - State: one `<!-- FORGE:INTEGRATION {json} -->` block in the GitHub milestone description (GitHub is the register):
10
+ * lastRun, lastGreen {sha, at, url}, redStreak, mergesSince, mergedPrs, issue.
11
+ * - Red run: ONE `integration-red` issue per milestone (marker FORGE:INTEGRATION:<slug>), created or its body updated,
12
+ * with the failing specs and the merge range. Green: the issue is commented and closed, the streak resets.
13
+ * - block_admission_after: the orchestrate seam `holds` refuses new lanes of the milestone while the red streak is at
14
+ * or over N, until a green run.
15
+ * The workflow is dispatched on the milestone branch and awaited through runCiVerify (verify.mjs).
16
+ */
17
+ import { milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
18
+ import { readMilestone } from "./drift.mjs";
19
+ import { runCiVerify } from "./verify.mjs";
20
+ import { parseForgeYaml } from "../forge-utils.mjs";
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ export const STATE_MARKER = "FORGE:INTEGRATION";
25
+ export const RED_LABEL = "integration-red";
26
+ const STATE_RE = /<!-- FORGE:INTEGRATION (\{[\s\S]*?\}) -->/;
27
+ const MAX_PRS = 100;
28
+
29
+ const arr = (x) => (Array.isArray(x) ? x : []);
30
+ const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
31
+ const posInt = (v) => { const n = Number(v); return str(v) !== "" && Number.isInteger(n) && n > 0 ? n : null; };
32
+ const short = (s) => String(s ?? "").slice(0, 7);
33
+
34
+ /**
35
+ * forge.yaml `ci` section → {workflow, project, projectInput, intervalHours, everyMerges, blockAdmissionAfter, maxMinutes}
36
+ * or null when no valid `workflow` is configured (the feature is off: nothing runs, files or holds).
37
+ */
38
+ export function parseIntegration(sec) {
39
+ const s = sec && typeof sec === "object" ? sec : {};
40
+ const workflow = /^[A-Za-z0-9._/-]+$/.test(str(s.workflow)) ? str(s.workflow) : null;
41
+ if (!workflow) return null;
42
+ const hours = Number(s.interval_hours);
43
+ return {
44
+ workflow,
45
+ project: str(s.project) || null,
46
+ projectInput: /^[A-Za-z_][A-Za-z0-9_-]*$/.test(str(s.project_input)) ? str(s.project_input) : "project",
47
+ intervalHours: str(s.interval_hours) !== "" && Number.isFinite(hours) && hours > 0 ? hours : 24,
48
+ everyMerges: posInt(s.every_merges),
49
+ blockAdmissionAfter: posInt(s.block_admission_after),
50
+ maxMinutes: posInt(s.max_minutes) ?? 360,
51
+ };
52
+ }
53
+
54
+ /** forge.yaml `ci:` of the checkout through parseIntegration; null when absent or unreadable. */
55
+ export function readIntegrationConfig(cwd = process.cwd()) {
56
+ try { return parseIntegration(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.ci); } catch { return null; }
57
+ }
58
+
59
+ export const emptyState = () => ({ v: 1, lastRun: null, lastGreen: null, redStreak: 0, mergesSince: 0, mergedPrs: [], issue: null });
60
+
61
+ /** The FORGE:INTEGRATION state in a milestone description (defaults filled), or null when there is no block. */
62
+ export function parseIntegrationState(description) {
63
+ const m = STATE_RE.exec(String(description ?? ""));
64
+ if (!m) return null;
65
+ try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) ? { ...emptyState(), ...s } : null; } catch { return null; }
66
+ }
67
+
68
+ /** `description` with its FORGE:INTEGRATION block replaced (or appended); the text around it is unchanged. */
69
+ export function renderState(description, state) {
70
+ const d = String(description ?? "");
71
+ const block = `<!-- ${STATE_MARKER} ${JSON.stringify(state).replace(/>/g, "\\u003e")} -->`;
72
+ if (STATE_RE.test(d)) return d.replace(STATE_RE, () => block);
73
+ return d ? `${d}\n\n${block}` : block;
74
+ }
75
+
76
+ /** Due when never run, `intervalHours` elapsed, or `everyMerges` lane merges since the last run. */
77
+ export function isDue(state, cfg, nowMs) {
78
+ const s = state ?? emptyState();
79
+ if (!s.lastRun?.at) return { due: true, reason: "never run" };
80
+ if (cfg.everyMerges && s.mergesSince >= cfg.everyMerges) return { due: true, reason: `${s.mergesSince} lane merge(s) since the last run` };
81
+ const age = nowMs - Date.parse(s.lastRun.at);
82
+ if (!Number.isFinite(age) || age >= cfg.intervalHours * 3600000) return { due: true, reason: `last run ${Number.isFinite(age) ? `${Math.round(age / 3600000)}h` : "unknown"} ago` };
83
+ return { due: false, reason: "not due" };
84
+ }
85
+
86
+ const SPEC_RE = /[\w@./-]+\.(?:e2e-spec|e2e|spec|test)\.[cm]?[jt]sx?/g;
87
+ /** Spec files named in the failed-job logs (sorted, at most 50). */
88
+ export function failingSpecs(failures) {
89
+ const out = new Set();
90
+ for (const f of arr(failures)) for (const m of String(f?.log ?? "").match(SPEC_RE) ?? []) out.add(m.replace(/^\.\//, ""));
91
+ return [...out].sort().slice(0, 50);
92
+ }
93
+
94
+ export const redMarker = (slug) => `${STATE_MARKER}:${slug}`;
95
+
96
+ /** Title and body of the integration-red issue. */
97
+ export function redIssue({ slug, milestone, head, state, failures, specs, runUrl, repo, at }) {
98
+ const g = state.lastGreen;
99
+ const range = g?.sha ? `\`${short(g.sha)}..${short(head)}\` ([compare](https://github.com/${repo}/compare/${g.sha}...${head}))` : `no green run yet; up to \`${short(head)}\``;
100
+ const prs = arr(state.mergedPrs);
101
+ const body = [
102
+ `The scheduled full-suite integration run on \`milestone/${slug}\` is red (${state.redStreak} consecutive red run${state.redStreak === 1 ? "" : "s"}). The run is report-only.`, "",
103
+ `- Run: ${runUrl ?? "n/a"} at \`${short(head)}\` (${at})`,
104
+ `- Merge range since the last green run: ${range}`,
105
+ `- Lane PRs merged in that range: ${prs.length ? prs.map((p) => `#${p}`).join(", ") : "none recorded"}`,
106
+ `- Failing jobs: ${arr(failures).map((f) => f.job).filter(Boolean).join(", ") || "n/a"}`, "",
107
+ "### Failing specs", "", ...(specs.length ? specs.map((s) => `- \`${s}\``) : ["- none named in the failed-job logs; see the run"]), "",
108
+ "This issue is a ship-gate risk for the milestone and closes itself on the next green integration run.", "",
109
+ `<!-- ${redMarker(slug)} -->`,
110
+ ].join("\n");
111
+ return { title: `integration-red(milestone/${slug}): full suite failing at ${short(head)}`, body, marker: redMarker(slug), milestone };
112
+ }
113
+
114
+ /** Open issue of the milestone carrying `marker`: its number, or null. */
115
+ async function findRed({ gh, repo, milestone, marker }) {
116
+ const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", milestone, "--state", "open", "--limit", "500", "--json", "number,body"])) || "[]");
117
+ const hit = arr(open).find((x) => String(x?.body ?? "").includes(`<!-- ${marker} -->`));
118
+ return hit ? Number(hit.number) : null;
119
+ }
120
+
121
+ /** Create the one integration-red issue, or replace the body of the open one. Resolves its number. */
122
+ export async function fileOrUpdateRed({ gh, repo, issue }) {
123
+ const n = await findRed({ gh, repo, milestone: issue.milestone, marker: issue.marker });
124
+ if (n) { await gh(["issue", "edit", String(n), "-R", repo, "--title", issue.title, "--body", issue.body]); return { number: n, created: false }; }
125
+ const base = ["issue", "create", "-R", repo, "--title", issue.title, "--body", issue.body, "--milestone", issue.milestone];
126
+ let out;
127
+ try { out = String(await gh([...base, "--label", RED_LABEL])); } catch { out = String(await gh(base)); } // the label may not exist in the repo yet
128
+ return { number: Number(/\/issues\/(\d+)/.exec(out)?.[1]) || null, created: true };
129
+ }
130
+
131
+ /** Green: comment on and close the open integration-red issue of the milestone, if any. */
132
+ export async function closeRed({ gh, repo, milestone, slug, head }) {
133
+ const n = await findRed({ gh, repo, milestone, marker: redMarker(slug) });
134
+ if (n) await gh(["issue", "close", String(n), "-R", repo, "--comment", `Integration run green at \`${short(head)}\`; closing.`]);
135
+ return n;
136
+ }
137
+
138
+ const branchOf = (pattern, title) => { const slug = milestoneSlug(title); return slug ? { slug, branch: String(pattern || DEFAULT_FEATURE_PATTERN).replace("{slug}", slug) } : null; };
139
+
140
+ /** Head SHA of `branch`, or null when it does not exist. */
141
+ async function branchHead(gh, repo, branch) {
142
+ try { return str(await gh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`, "--jq", ".commit.sha"])) || null; } catch { return null; }
143
+ }
144
+
145
+ /**
146
+ * One integration run of one milestone: dispatch, wait, record. Resolves {title, ran, result, detail, state}. `ran` is
147
+ * false when the milestone is skipped (no branch, not due); a timeout or unavailable run records nothing and retries next time.
148
+ */
149
+ export async function runMilestone({ io, repo, ms, cfg, pattern, nowFn = () => Date.now(), sleep, random, force = false, onPoll }) {
150
+ const gh = io.gh, b = branchOf(pattern, ms.title);
151
+ if (!b) return { title: ms.title, ran: false, detail: "milestone gives an empty branch slug" };
152
+ const head = await branchHead(gh, repo, b.branch);
153
+ if (!head) return { title: ms.title, ran: false, detail: `branch ${b.branch} not found` };
154
+ const before = parseIntegrationState(ms.description) ?? emptyState();
155
+ const due = isDue(before, cfg, nowFn());
156
+ if (!force && !due.due) return { title: ms.title, ran: false, detail: due.reason, state: before };
157
+ const v = await runCiVerify({ io, branch: b.branch, head, changedFiles: [cfg.project ?? "integration"], verify: { workflow: cfg.workflow, pathsInput: cfg.project ? cfg.projectInput : null, skipPaths: [] },
158
+ ciMaxMinutes: cfg.maxMinutes, now: nowFn, sleep, random, ...(onPoll ? { onPoll } : {}) });
159
+ if (v.result !== "pass" && v.result !== "fail") return { title: ms.title, ran: true, result: v.result, detail: v.detail, state: before };
160
+ const at = new Date(nowFn()).toISOString();
161
+ const state = { ...before, lastRun: { sha: head, at, result: v.result, url: v.runUrl ?? null }, mergesSince: 0 };
162
+ if (v.result === "pass") {
163
+ state.lastGreen = { sha: head, at, url: v.runUrl ?? null }; state.redStreak = 0; state.mergedPrs = []; state.issue = null;
164
+ await closeRed({ gh, repo, milestone: ms.title, slug: b.slug, head });
165
+ } else {
166
+ state.redStreak = (before.redStreak ?? 0) + 1;
167
+ const specs = failingSpecs(v.failures);
168
+ const filed = await fileOrUpdateRed({ gh, repo, issue: redIssue({ slug: b.slug, milestone: ms.title, head, state: { ...state, blockAdmissionAfter: cfg.blockAdmissionAfter }, failures: v.failures, specs, runUrl: v.runUrl, repo, at }) });
169
+ state.issue = filed.number;
170
+ }
171
+ await gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderState(ms.description, state)}`]);
172
+ return { title: ms.title, ran: true, result: v.result, detail: v.detail, state };
173
+ }
174
+
175
+ /** Every open milestone → {number, title, description}. */
176
+ export async function listOpenMilestones({ gh, repo }) {
177
+ return arr(JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=open&per_page=100`])) || "[]")).map((m) => ({ number: m.number, title: m.title, description: m.description ?? "" }));
178
+ }
179
+
180
+ /** Run every due active milestone (optionally only `only`, a title). Sequential: one suite at a time. Resolves the per-milestone results. */
181
+ export async function runIntegration({ io, repo, cfg, pattern, only = null, ...rest }) {
182
+ if (!cfg) return [];
183
+ const out = [];
184
+ for (const ms of await listOpenMilestones({ gh: io.gh, repo })) {
185
+ if (only && ms.title !== only) continue;
186
+ try { out.push(await runMilestone({ io, repo, ms, cfg, pattern, ...rest })); } catch (e) { out.push({ title: ms.title, ran: false, detail: `failed: ${String(e?.message ?? e).slice(0, 200)}` }); }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * The orchestrate seam: `{ onMerged(issue, row, result), holds(issue, {lane}), prime(), settle() }`, or null when `cfg` is null.
193
+ * onMerged counts the merge into the milestone state and, at `every_merges`, starts a run in the background (never in the
194
+ * lane's slot); `settle()` waits for those. `prime()` loads the red streaks `holds` reads (synchronously) from the milestones.
195
+ */
196
+ export function makeIntegration({ repo, io, cfg, pattern = DEFAULT_FEATURE_PATTERN, nowFn = () => Date.now(), sleep, random, onEvent = () => {} }) {
197
+ if (!cfg) return null;
198
+ const streaks = new Map(); // lane -> consecutive red runs
199
+ const running = new Set();
200
+ const pending = new Set();
201
+ let chain = Promise.resolve();
202
+ const lane = (title) => { const b = branchOf(pattern, title); return b ? b.branch : null; };
203
+ const note = (ms, state) => { const l = lane(ms.title); if (l) streaks.set(l, state?.redStreak ?? 0); };
204
+
205
+ async function count(issue, result) {
206
+ const title = result?.milestone;
207
+ if (!title) return;
208
+ const ms = await readMilestone({ gh: io.gh, repo, title });
209
+ if (!ms) return;
210
+ const full = { ...ms, title };
211
+ const s = parseIntegrationState(ms.description) ?? emptyState();
212
+ const pr = result.pr ?? null;
213
+ const next = { ...s, mergesSince: (s.mergesSince ?? 0) + 1, mergedPrs: pr != null && !s.mergedPrs.includes(pr) ? [...s.mergedPrs, pr].slice(-MAX_PRS) : s.mergedPrs };
214
+ const desc = renderState(ms.description, next);
215
+ await io.gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${desc}`]);
216
+ note(full, next);
217
+ if (!cfg.everyMerges || next.mergesSince < cfg.everyMerges || running.has(title)) return;
218
+ running.add(title);
219
+ const p = runMilestone({ io, repo, ms: { ...full, description: desc }, cfg, pattern, nowFn, sleep, random })
220
+ .then((r) => { if (r.state) note(full, r.state); onEvent({ issue, detail: `integration: ${title} ${r.result ?? r.detail}` }); })
221
+ .catch((e) => onEvent({ issue, detail: `integration run failed: ${String(e?.message ?? e).slice(0, 200)}` }))
222
+ .finally(() => { running.delete(title); pending.delete(p); });
223
+ pending.add(p);
224
+ }
225
+
226
+ return {
227
+ onMerged(issue, row, result) {
228
+ const p = chain.then(() => count(issue, result));
229
+ chain = p.catch(() => {});
230
+ return p;
231
+ },
232
+ holds(issue, { lane: l } = {}) {
233
+ if (!cfg.blockAdmissionAfter) return null;
234
+ const n = streaks.get(String(l ?? ""));
235
+ return n >= cfg.blockAdmissionAfter ? `${n} consecutive red integration runs on ${l}` : null;
236
+ },
237
+ async prime() {
238
+ for (const ms of await listOpenMilestones({ gh: io.gh, repo })) note(ms, parseIntegrationState(ms.description));
239
+ },
240
+ async settle() { await chain; while (pending.size) await Promise.allSettled([...pending]); },
241
+ };
242
+ }
243
+
244
+ /**
245
+ * `forgedock integration run [--repo owner/repo] [--milestone TITLE] [--force]`: one pass over the active milestones, for
246
+ * cron or a GitHub Actions schedule. Prints one line per milestone. Resolves the exit code (1 when a run was red).
247
+ * `forgedock integration status [--repo owner/repo]`: the last green full-suite run of each active milestone (those with
248
+ * a state block, or all when `ci.workflow` is set), for /pipeline-status.
249
+ */
250
+ export async function runIntegrationCli(argv, deps = {}) {
251
+ const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
252
+ if (argv[0] === "status") {
253
+ const repo = val("--repo") ?? deps.repo;
254
+ if (!repo) throw new Error("integration needs the repo: pass --repo owner/repo");
255
+ const io = deps.io ?? (await import("./effects.mjs")).makeLeanIo({ repo });
256
+ const on = deps.cfg !== undefined ? !!deps.cfg : !!readIntegrationConfig(deps.cwd ?? process.cwd());
257
+ for (const ms of await listOpenMilestones({ gh: io.gh, repo })) {
258
+ const s = parseIntegrationState(ms.description);
259
+ if (s || on) (deps.print ?? ((t) => console.log(t)))(` integration: ${ms.title}: last green full-suite run ${s?.lastGreen?.at ?? "never"} (red streak ${s?.redStreak ?? 0})`);
260
+ }
261
+ return 0;
262
+ }
263
+ if (argv[0] !== "run") throw new Error("usage: forgedock integration run|status [--repo owner/repo] [--milestone TITLE] [--force]");
264
+ const cwd = deps.cwd ?? process.cwd();
265
+ const cfg = deps.cfg !== undefined ? deps.cfg : readIntegrationConfig(cwd);
266
+ const print = deps.print ?? ((t) => console.log(t));
267
+ if (!cfg) { print("integration: no ci.workflow configured in forge.yaml; nothing to run"); return 0; }
268
+ const repo = val("--repo") ?? deps.repo;
269
+ if (!repo) throw new Error("integration needs the repo: pass --repo owner/repo");
270
+ const io = deps.io ?? (await import("./effects.mjs")).makeLeanIo({ repo });
271
+ const pattern = deps.pattern ?? readBranches(cwd).featurePattern ?? DEFAULT_FEATURE_PATTERN;
272
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
273
+ const res = await runIntegration({ io, repo, cfg, pattern, only: val("--milestone"), force: argv.includes("--force"), nowFn: deps.now ?? (() => Date.now()), sleep, random: deps.random ?? Math.random });
274
+ for (const r of res) print(`integration: ${r.title}: ${r.ran ? r.result ?? "n/a" : "skipped"}${r.detail ? ` (${r.detail})` : ""}`);
275
+ return res.some((r) => r.result === "fail") ? 1 : 0;
276
+ }