forgedock 1.49.0 → 1.51.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 (44) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -0
  2. package/bin/engine-cli.mjs +123 -20
  3. package/bin/forgedock.mjs +31 -3
  4. package/bin/lean/batch-health.mjs +219 -0
  5. package/bin/lean/batch-health.test.mjs +156 -0
  6. package/bin/lean/batch.mjs +12 -3
  7. package/bin/lean/batch.test.mjs +304 -8
  8. package/bin/lean/conformance.mjs +79 -7
  9. package/bin/lean/conformance.test.mjs +63 -1
  10. package/bin/lean/context.mjs +8 -2
  11. package/bin/lean/context.test.mjs +11 -1
  12. package/bin/lean/credentials.mjs +18 -0
  13. package/bin/lean/execution.mjs +23 -9
  14. package/bin/lean/execution.test.mjs +93 -0
  15. package/bin/lean/fixclosereasons.mjs +52 -0
  16. package/bin/lean/fixclosereasons.test.mjs +64 -0
  17. package/bin/lean/heavy.mjs +14 -4
  18. package/bin/lean/heavy.test.mjs +55 -1
  19. package/bin/lean/lean.mjs +93 -36
  20. package/bin/lean/lean.test.mjs +143 -19
  21. package/bin/lean/live-execution.mjs +136 -0
  22. package/bin/lean/live-execution.test.mjs +83 -0
  23. package/bin/lean/orchestrate-blocked.test.mjs +47 -0
  24. package/bin/lean/orchestrate.mjs +135 -17
  25. package/bin/lean/prefilter.mjs +4 -3
  26. package/bin/lean/prefilter.test.mjs +5 -4
  27. package/bin/lean/prompts.mjs +4 -2
  28. package/bin/lean/recovery.test.mjs +5 -5
  29. package/bin/lean/sandbox-provider.contract.test.mjs +36 -1
  30. package/bin/lean/sandbox-remote.mjs +6 -2
  31. package/bin/lean/schedd.mjs +9 -0
  32. package/bin/pool/agent.mjs +3 -1
  33. package/bin/pool/router.mjs +72 -7
  34. package/bin/review-staging.mjs +2 -0
  35. package/bin/runner.mjs +24 -0
  36. package/commands/orchestrate.md +2 -1
  37. package/commands/review-pr-staging.md +2 -2
  38. package/docs/CONFIG.md +39 -1
  39. package/docs/OUTCOME-CODES.md +2 -1
  40. package/package.json +1 -1
  41. package/pool/README.md +2 -0
  42. package/pool/image/cs-cred +8 -1
  43. package/scripts/review-staging-post.sh +70 -25
  44. package/scripts/review-staging-post.test.sh +40 -8
@@ -212,9 +212,15 @@ const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.f
212
212
  const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
213
213
  const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
214
214
 
215
+ /**
216
+ * #5152: a closed record's result for the builder context. Delivered (`workflow:merged`) or moved (`workflow:decomposed`)
217
+ * scope reads `completed` whatever its `stateReason`, so a legacy NOT_PLANNED close of delivered work never reads as dropped.
218
+ */
219
+ export const closedResult = (rec) => (["workflow:merged", "workflow:decomposed"].some((l) => (rec?.labels ?? []).includes(l)) ? "completed" : rec?.stateReason ?? "closed");
215
220
  async function readIssue(gh, repo, n) {
216
221
  const j = JSON.parse(String(await gh(["api", `repos/${repo}/issues/${n}`])));
217
- return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request) };
222
+ return { number: Number(j.number ?? n), title: oneLine(j.title, 120), state: String(j.state ?? "").toUpperCase() || null, stateReason: j.state_reason ?? null, body: String(j.body ?? ""), pr: Boolean(j.pull_request),
223
+ labels: (Array.isArray(j.labels) ? j.labels : []).map((l) => String(l?.name ?? l)) };
218
224
  }
219
225
  async function readComments(gh, repo, n) {
220
226
  return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
@@ -324,7 +330,7 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
324
330
  const st = stateOf(rec.body);
325
331
  const reason = codeOf(st?.terminalReason), fails = failuresOf(st);
326
332
  facts.links.push({ number: n, rel: r, kind: rec.pr ? "pr" : "issue", title: rec.title, state: rec.state, code: outcomeOf(st),
327
- result: reason ?? (rec.state === "CLOSED" ? rec.stateReason ?? "closed" : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
333
+ result: reason ?? (rec.state === "CLOSED" ? closedResult(rec) : null), ...(fails.length ? { failures: fails } : {}), ...(r === "parent" || r === "depends-on" ? { body: stripComments(rec.body) } : {}) });
328
334
  }
329
335
  });
330
336
 
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
6
- import { gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
6
+ import { closedResult, gatherContextFacts, compileContextPack, contextPackFor, contextArm, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments, subjectTerms, subjectHistory, MAX_SUBJECT_ITEMS, MAX_SUBJECT_QUERIES } from "./context.mjs";
7
7
 
8
8
  const REPO = "o/r";
9
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -570,3 +570,13 @@ describe("#5085: subject history and recall status", () => {
570
570
  assert.equal(compileContextPack({ issue: 1, links: [], attempts: [], ledger: [], facts: [], recall: absent.recall }).recall.present, false, "the empty pack carries it too");
571
571
  });
572
572
  });
573
+
574
+ describe("#5152 builder context: delivered or moved scope never reads as dropped", () => {
575
+ it("a closed workflow:merged or workflow:decomposed dependency is completed whatever its stateReason (legacy not_planned too)", () => {
576
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:merged"] }), "completed");
577
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["bug", "workflow:decomposed"] }), "completed");
578
+ assert.equal(closedResult({ stateReason: "not_planned", labels: ["workflow:invalid"] }), "not_planned", "dropped scope keeps its reason");
579
+ assert.equal(closedResult({ stateReason: "completed", labels: [] }), "completed");
580
+ assert.equal(closedResult({ stateReason: null }), "closed");
581
+ });
582
+ });
@@ -27,3 +27,21 @@ export function resolveCredential(name, { env = process.env, keychain = defaultK
27
27
  if (v !== undefined && v !== null && String(v) !== "") return String(v);
28
28
  try { const k = keychain(String(name), platform); return k === undefined || k === null || String(k) === "" ? null : String(k); } catch { return null; }
29
29
  }
30
+
31
+ /**
32
+ * #5159: a keychain lookup memoised per name for `ttlMs` (a miss or a failure is cached as null too), so a resolver run
33
+ * on every request (#5144) spawns `secret-tool`/`security` at most once per TTL instead of on every HTTP call.
34
+ */
35
+ export function memoKeychain({ keychain = defaultKeychain, ttlMs = 60000, now = Date.now } = {}) {
36
+ const cache = new Map();
37
+ return (name, platform) => {
38
+ const key = `${platform ?? ""}\u0000${name}`;
39
+ const hit = cache.get(key);
40
+ const t = now();
41
+ if (hit && t - hit.at < ttlMs) return hit.value;
42
+ let value = null;
43
+ try { const k = keychain(name, platform); value = k === undefined || k === null || String(k) === "" ? null : String(k); } catch { value = null; }
44
+ cache.set(key, { at: t, value });
45
+ return value;
46
+ };
47
+ }
@@ -58,7 +58,7 @@ import { isLaneAlive, launchLane, writeAtomic } from "./batch.mjs";
58
58
  import { schedDir } from "./sched.mjs";
59
59
  import { dockerLabels, safeSegment } from "./ledger.mjs";
60
60
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
61
- import { resolveCredential } from "./credentials.mjs";
61
+ import { resolveCredential, memoKeychain, defaultKeychain } from "./credentials.mjs";
62
62
  import { httpTransport, isActionsEndpoint, remoteProvider } from "./sandbox-remote.mjs";
63
63
  import { ADMISSION_DEFAULTS } from "./admission.mjs";
64
64
  import { classifyStep, heavyConfig, memAvailableMb } from "./heavy.mjs";
@@ -515,17 +515,22 @@ export function poolCredential(exec = {}, { env = process.env, router = null, ke
515
515
  /**
516
516
  * The remote transport for forge.yaml `execution` settings: `opts.transport` when injected, else `httpTransport` on
517
517
  * `exec.endpoint` with the credential `exec.tokenEnv` from `credentials`, or by default poolCredential (#5131: env,
518
- * keychain, `token_file`, the matching local router's token). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
518
+ * keychain, `token_file`, the matching local router's token; #5159: the keychain lookup is memoised per transport, `keychain`
519
+ * is its seam). `fetchFn` is a test seam. Throws `execution.provider.misconfigured`
519
520
  * without an endpoint, `execution.provider.actions_refused` for a GitHub Actions endpoint without `allow_actions`.
520
521
  */
521
- export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn } = {}) {
522
+ export function remoteTransportFor(exec = {}, { transport = null, env = process.env, credentials = null, router, fetchFn, keychain } = {}) {
522
523
  if (transport) return transport;
523
524
  if (!exec?.endpoint) throw typedError("execution.provider.misconfigured", "execution.provider remote needs execution.endpoint");
524
525
  if (isActionsEndpoint(exec.endpoint) && exec.allowActions !== true) {
525
526
  throw typedError("execution.provider.actions_refused", "execution.endpoint is a GitHub Actions API; set execution.allow_actions: true to spend Actions minutes");
526
527
  }
527
- const token = credentials ? (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
528
- : poolCredential({ provider: "remote", ...exec }, { env, router: router !== undefined ? router : poolRouterConfig({ env }) }).token;
528
+ // #5144: a getter, so the bearer is resolved on every request (a token fixed or rotated mid-batch is picked up), not once here.
529
+ const rt = credentials ? null : (router !== undefined ? router : poolRouterConfig({ env }));
530
+ // #5159: the env and token files are re-read per request; the sync keychain lookup is memoised (misses too) for its TTL.
531
+ const kc = credentials ? null : memoKeychain({ keychain: keychain ?? defaultKeychain });
532
+ const token = credentials ? () => (exec.tokenEnv ? credentials(exec.tokenEnv) : null)
533
+ : () => poolCredential({ provider: "remote", ...exec }, { env, router: rt, keychain: kc }).token;
529
534
  return httpTransport({ endpoint: exec.endpoint, token, ...(fetchFn ? { fetchFn } : {}) });
530
535
  }
531
536
 
@@ -556,13 +561,22 @@ export function providerByName(name, opts) {
556
561
  let forgeYaml = null;
557
562
  if (pool) { try { forgeYaml = opts?.cwd ? readFileSync(join(opts.cwd, "forge.yaml"), "utf8") : null; } catch { forgeYaml = null; } }
558
563
  const endpoint = exec.endpoint ?? (pool ? router?.endpoint : undefined);
559
- const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router });
560
- const resolved = resolve();
564
+ // #5144: the router's token file is re-read per call (a rotated or newly written token), its endpoint stays as at construction.
565
+ const routerEligible = !(opts?.transport || (pool && exec.endpoint));
566
+ const routerNow = () => {
567
+ if (!routerEligible) return null;
568
+ const c = poolRouterConfig({ env, ...(opts?.poolConfigPath ? { path: opts.poolConfigPath } : {}) });
569
+ return c ? { endpoint: router?.endpoint ?? c.endpoint, token: c.token } : router;
570
+ };
571
+ // #5159: one memoised keychain per provider, so a `token_env` absent from the env does not spawn the keychain per request.
572
+ const keychain = memoKeychain({ keychain: opts?.keychain ?? defaultKeychain });
573
+ const resolve = () => poolCredential({ ...exec, provider: name, ...(endpoint ? { endpoint } : {}) }, { env, router: routerNow(), keychain });
561
574
  // A token read from a file is resolved under a fixed name so remoteProvider redacts it like any `token_env` credential.
575
+ // #5144: set even when no token exists yet, so one that appears later is still resolved per call (and redacted).
562
576
  const fixed = pool ? "FORGEDOCK_POOL_TOKEN" : "FORGEDOCK_REMOTE_TOKEN";
563
- const tokenEnv = exec.tokenEnv ?? (resolved.token ? fixed : undefined);
577
+ const tokenEnv = exec.tokenEnv ?? fixed;
564
578
  // #4685/#5131: an empty env/keychain lookup of `token_env` falls back to `token_file`, then the (matching) router's token file.
565
- const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env })); // per call, like resolveCredential
579
+ const credentials = (n) => (n === tokenEnv ? resolve().token : resolveCredential(n, { env, keychain })); // per call, keychain memoised (#5159)
566
580
  const settings = { ...exec, ...(endpoint ? { endpoint } : {}), ...(tokenEnv ? { tokenEnv } : {}), ...(forgeYaml ? { forgeYaml } : {}),
567
581
  ...(typeof opts?.prefetch === "function" ? { prefetch: opts.prefetch } : {}) }; // #4778: dispatch-time relay pre-fetch
568
582
  return remoteProvider({ batch: opts.batch, repo: opts.repo, settings, ...(pool ? { name: "pool" } : {}), env, credentials,
@@ -399,6 +399,27 @@ describe("runBatch wiring (#4253)", () => {
399
399
  assert.deepEqual(prov.calls, []);
400
400
  }
401
401
  });
402
+
403
+ it("#5138: a live --set-placement local makes the next lane of a remote batch a detached host lane", async () => {
404
+ const work = tmpDir("exec-cli-live-");
405
+ const stub = join(work, "stub.mjs");
406
+ writeFileSync(stub, `import { writeFileSync } from "node:fs"; const a = process.argv.slice(2); writeFileSync(a[a.indexOf("--result-file") + 1], JSON.stringify({ terminalReason: "merged", pr: 9 }));\n`);
407
+ const root = join(work, "batches");
408
+ let recorded = null;
409
+ const prov = fakeProvider({ finish: (n) => {
410
+ const b = openBatch(root, "blive");
411
+ recorded = b.readExecution();
412
+ if (n === 1) b.setExecution({ mode: "local" }); // the operator's --set-placement before #2 is admitted
413
+ return { terminalReason: "merged", pr: 100 + n, cost_usd: 0 };
414
+ } });
415
+ const res = await quiet(() => orchestrateFromCli(["1", "2", "--repo", "o/r", "--no-prefilter", "--parallel", "1"], base(work, { batchId: "blive", laneScript: stub,
416
+ execution: parseExecution({ mode: "remote", max_remote: "1" }), sandboxProvider: prov,
417
+ sandboxLedger: (n) => makeLedger({ root: join(work, "ledger"), batch: "blive", lane: `sandbox-${n}`, owner: "t", issue: n }) })));
418
+ assert.deepEqual(recorded, { provider: true });
419
+ assert.deepEqual(res.rows.map((r) => [r.issue, r.outcome, r.pr, r.placement?.where]), [[1, "merged", 101, "remote"], [2, "merged", 9, "local"]]);
420
+ assert.match(res.rows[1].placement.reason, /live placement/);
421
+ assert.deepEqual(prov.calls.filter((c) => c[0] === "start").map((c) => c[1]), [1]);
422
+ });
402
423
  });
403
424
 
404
425
  describe("execution budget and sandbox cost (#4255)", () => {
@@ -960,6 +981,78 @@ describe("remote/pool credential resolver (#5131)", () => {
960
981
  assert.equal(hit.seen[0].auth, "Bearer file-tok");
961
982
  });
962
983
 
984
+ it("5144: a token that appears or rotates after the provider is built is the next request's bearer", async () => {
985
+ const dir = tmpDir("router-5144-");
986
+ const tokenFile = join(dir, "forgedock.token");
987
+ writeFileSync(join(dir, "router.json"), JSON.stringify({ listen: { host: "127.0.0.1", port: 7710 } }));
988
+ writeFileSync(tokenFile, "\n"); // router config with no token yet
989
+ const hit = recorder();
990
+ const env = {};
991
+ const mk = (name, exec) => providerByName(name, { batch: { id: "b5144" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: name, ...exec }),
992
+ env, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
993
+ for (const [name, exec] of [["remote", { endpoint: "http://127.0.0.1:7710" }], ["pool", {}]]) {
994
+ writeFileSync(tokenFile, "\n");
995
+ hit.seen.length = 0;
996
+ const p = mk(name, exec);
997
+ await p.capacity();
998
+ assert.equal(hit.seen.at(-1).auth, null, `${name}: no token at construction`);
999
+ writeFileSync(tokenFile, "router-new\n");
1000
+ await p.capacity();
1001
+ assert.equal(hit.seen.at(-1).auth, "Bearer router-new", `${name}: a token written later is sent`);
1002
+ writeFileSync(tokenFile, "router-rotated\n");
1003
+ await p.capacity();
1004
+ assert.equal(hit.seen.at(-1).auth, "Bearer router-rotated", `${name}: a rotated token is sent`);
1005
+ }
1006
+ });
1007
+
1008
+ it("5144: an env token_env that is set after construction is sent; remoteTransportFor default path resolves per call", async () => {
1009
+ const dir = routerDir();
1010
+ const hit = recorder();
1011
+ const penv = {};
1012
+ const q = providerByName("remote", { batch: { id: "b5144e" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN" }),
1013
+ env: penv, poolConfigPath: join(dir, "router.json"), fetchFn: hit.fetchFn });
1014
+ await q.capacity();
1015
+ assert.equal(hit.seen.at(-1).auth, null);
1016
+ penv.COMPUTESHARE_TOKEN = "env-late";
1017
+ await q.capacity();
1018
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
1019
+ const tenv = {};
1020
+ const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN" }, { env: tenv, router: null, fetchFn: hit.fetchFn });
1021
+ await t.capacity();
1022
+ assert.equal(hit.seen.at(-1).auth, null);
1023
+ tenv.COMPUTESHARE_TOKEN = "env-late-2";
1024
+ await t.capacity();
1025
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
1026
+ });
1027
+
1028
+ it("5159: a token_env absent from the env runs the keychain once, not on every request; the token_file bearer and a later env token still apply", async () => {
1029
+ const dir = tmpDir("kc-5159-");
1030
+ const file = join(dir, "explicit.token");
1031
+ writeFileSync(file, "file-tok\n");
1032
+ const hit = recorder();
1033
+ let calls = 0;
1034
+ const keychain = () => { calls++; return null; };
1035
+ const env = {};
1036
+ const p = providerByName("remote", { batch: { id: "b5159" }, repo: "o/r", exec: parseExecution({ mode: "remote", max_remote: "1", provider: "remote", endpoint: "https://runner.example.com", token_env: "COMPUTESHARE_TOKEN", token_file: file }),
1037
+ env, poolConfigPath: join(dir, "none.json"), fetchFn: hit.fetchFn, keychain });
1038
+ for (let i = 0; i < 4; i++) await p.capacity();
1039
+ assert.ok(hit.seen.length >= 4);
1040
+ assert.ok(hit.seen.every((x) => x.auth === "Bearer file-tok"), "every request carries the token_file bearer");
1041
+ assert.ok(calls <= 1, `keychain ran ${calls} times`);
1042
+ env.COMPUTESHARE_TOKEN = "env-late";
1043
+ await p.capacity();
1044
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late");
1045
+ let tcalls = 0;
1046
+ const tenv = {};
1047
+ const t = remoteTransportFor({ endpoint: "https://runner.example.com", tokenEnv: "COMPUTESHARE_TOKEN", tokenFile: file }, { env: tenv, router: null, fetchFn: hit.fetchFn, keychain: () => { tcalls++; return null; } });
1048
+ for (let i = 0; i < 4; i++) await t.capacity();
1049
+ assert.equal(hit.seen.at(-1).auth, "Bearer file-tok");
1050
+ assert.equal(tcalls, 1, "remoteTransportFor: one keychain lookup across requests");
1051
+ tenv.COMPUTESHARE_TOKEN = "env-late-2";
1052
+ await t.capacity();
1053
+ assert.equal(hit.seen.at(-1).auth, "Bearer env-late-2");
1054
+ });
1055
+
963
1056
  it("5131: remoteTransportFor's default credentials (gc) fall back to the matching router token", async () => {
964
1057
  const hit = recorder();
965
1058
  const t = remoteTransportFor({ endpoint: "http://127.0.0.1:7710" }, { env: {}, router: { endpoint: "http://127.0.0.1:7710", token: "router-tok" }, fetchFn: hit.fetchFn });
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * `forgedock fix-close-reasons <owner/repo> [--milestone <m>] [--apply]` (#5177, deferred from #5152). No LLM.
5
+ * Migration helper: an issue closed `not planned` that carries `workflow:merged` or `workflow:decomposed` was delivered
6
+ * (or moved to sub-issues), not dropped. It is re-closed as `completed` with a comment. One PATCH changes the reason in place
7
+ * (no reopen window). A `workflow:invalid` issue is a real drop and is never touched. Dry-run unless `--apply`.
8
+ */
9
+ const DELIVERED = ["workflow:merged", "workflow:decomposed"];
10
+ const isNotPlanned = (r) => String(r ?? "").toUpperCase().replace(/\s/g, "_") === "NOT_PLANNED";
11
+
12
+ const commentFor = (label) => label === "workflow:decomposed"
13
+ ? "Close reason corrected to `completed`: this issue was decomposed and its scope moved to its sub-issues, it was not dropped (`workflow:decomposed`). Re-closed by `forgedock fix-close-reasons` (#5177)."
14
+ : "Close reason corrected to `completed`: the work was delivered and merged, it was not dropped (`workflow:merged`). Re-closed by `forgedock fix-close-reasons` (#5177).";
15
+
16
+ /** Closed `not planned` issues that were delivered: [{number, label}], by issue number, once each. */
17
+ export async function findMisclosed({ gh, repo, milestone }) {
18
+ const found = new Map();
19
+ for (const label of DELIVERED) {
20
+ const out = await gh(["issue", "list", "-R", repo, "--state", "closed", "--label", label, ...(milestone ? ["--milestone", milestone] : []), "--limit", "1000", "--json", "number,stateReason,labels"]);
21
+ for (const i of JSON.parse(String(out || "[]"))) {
22
+ const names = (i.labels ?? []).map((l) => (typeof l === "string" ? l : l.name));
23
+ if (!isNotPlanned(i.stateReason) || names.includes("workflow:invalid") || found.has(i.number)) continue;
24
+ found.set(i.number, { number: i.number, label });
25
+ }
26
+ }
27
+ return [...found.values()].sort((a, b) => a.number - b.number);
28
+ }
29
+
30
+ export async function runFixCloseReasonsCli(argv, deps = {}) {
31
+ const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
32
+ const repo = argv[0] && !argv[0].startsWith("--") ? argv[0] : (val("--repo") ?? deps.repo ?? null);
33
+ if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error("usage: forgedock fix-close-reasons <owner/repo> [--milestone <title|number>] [--apply]");
34
+ const milestone = val("--milestone");
35
+ if (argv.includes("--milestone") && (!milestone || milestone.startsWith("--"))) throw new Error("--milestone requires a milestone title or number");
36
+ const apply = argv.includes("--apply");
37
+ const gh = deps.gh ?? (await import("./effects.mjs")).makeLeanIo({ repo }).gh;
38
+ const print = deps.print ?? ((t) => console.log(t));
39
+ const items = await findMisclosed({ gh, repo, milestone });
40
+ let changed = 0, failed = 0;
41
+ for (const { number, label } of items) {
42
+ if (!apply) { print(`would re-close #${number} as completed (${label})`); continue; }
43
+ try {
44
+ await gh(["api", "-X", "PATCH", `repos/${repo}/issues/${number}`, "-f", "state=closed", "-f", "state_reason=completed"]);
45
+ await gh(["issue", "comment", String(number), "-R", repo, "--body", commentFor(label)]);
46
+ changed++;
47
+ print(`re-closed #${number} as completed (${label})`);
48
+ } catch (e) { failed++; print(`#${number}: failed: ${String(e?.message ?? e).slice(0, 160)}`); }
49
+ }
50
+ print(`fix-close-reasons: ${items.length} found, ${changed} changed, ${failed} failed${apply ? "" : " (dry run, pass --apply to write)"}`);
51
+ return failed ? 1 : 0;
52
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import assert from "node:assert/strict";
4
+ import { describe, it } from "node:test";
5
+ import { runFixCloseReasonsCli } from "./fixclosereasons.mjs";
6
+
7
+ const L = (...n) => n.map((name) => ({ name }));
8
+ function fake({ lists, failOn } = {}) {
9
+ const calls = [], out = [];
10
+ const gh = async (a) => {
11
+ calls.push(a);
12
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(lists[a[a.indexOf("--label") + 1]] ?? []);
13
+ if (a[0] === "api" && failOn === a[3].split("/").pop()) throw new Error("boom");
14
+ return "";
15
+ };
16
+ return { gh, calls, out, print: (t) => out.push(t) };
17
+ }
18
+ const lists = {
19
+ "workflow:merged": [
20
+ { number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged") },
21
+ { number: 2, stateReason: "COMPLETED", labels: L("workflow:merged") },
22
+ { number: 3, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:invalid") },
23
+ ],
24
+ "workflow:decomposed": [
25
+ { number: 4, stateReason: "NOT_PLANNED", labels: L("workflow:decomposed") },
26
+ { number: 1, stateReason: "NOT_PLANNED", labels: L("workflow:merged", "workflow:decomposed") },
27
+ ],
28
+ };
29
+
30
+ describe("#5177 fix-close-reasons", () => {
31
+ it("re-closes NOT_PLANNED merged and decomposed issues as completed with a comment; leaves the rest", async () => {
32
+ const f = fake({ lists });
33
+ assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 0);
34
+ const patches = f.calls.filter((a) => a[0] === "api");
35
+ assert.deepEqual(patches.map((a) => a[3]), ["repos/o/r/issues/1", "repos/o/r/issues/4"]);
36
+ for (const p of patches) assert.deepEqual(p.slice(0, 3).concat(p.slice(4)), ["api", "-X", "PATCH", "-f", "state=closed", "-f", "state_reason=completed"]);
37
+ const comments = f.calls.filter((a) => a[1] === "comment");
38
+ assert.deepEqual(comments.map((a) => a[2]), ["1", "4"]);
39
+ assert.match(comments[0][comments[0].indexOf("--body") + 1], /delivered and merged/);
40
+ assert.match(comments[1][comments[1].indexOf("--body") + 1], /decomposed/);
41
+ assert.match(f.out.at(-1), /2 found, 2 changed, 0 failed/);
42
+ });
43
+ it("is a dry run without --apply: no write", async () => {
44
+ const f = fake({ lists });
45
+ assert.equal(await runFixCloseReasonsCli(["o/r"], f), 0);
46
+ assert.equal(f.calls.filter((a) => a[0] !== "issue" || a[1] !== "list").length, 0);
47
+ assert.match(f.out.join("\n"), /would re-close #1/);
48
+ });
49
+ it("--milestone narrows the list query", async () => {
50
+ const f = fake({ lists: {} });
51
+ await runFixCloseReasonsCli(["o/r", "--milestone", "M1"], f);
52
+ for (const c of f.calls) assert.equal(c[c.indexOf("--milestone") + 1], "M1");
53
+ assert.equal(f.calls.length, 2);
54
+ });
55
+ it("counts a failed write and exits 1, continuing with the next issue", async () => {
56
+ const f = fake({ lists, failOn: "1" });
57
+ assert.equal(await runFixCloseReasonsCli(["o/r", "--apply"], f), 1);
58
+ assert.match(f.out.at(-1), /2 found, 1 changed, 1 failed/);
59
+ });
60
+ it("throws usage without a repo", async () => {
61
+ await assert.rejects(runFixCloseReasonsCli([], fake()), /usage: forgedock fix-close-reasons/);
62
+ await assert.rejects(runFixCloseReasonsCli(["--apply"], fake()), /usage/);
63
+ });
64
+ });
@@ -266,10 +266,16 @@ export function heavyShimScript(name, real, { node = process.execPath, script =
266
266
  return `${SHIM_HEADER}export FORGE_HEAVY_SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"\nexec ${q(node)} ${q(script)} run --name ${q(name)} -- ${q(real)} "$@"\n`;
267
267
  }
268
268
 
269
- /** True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). */
269
+ /**
270
+ * True when `text` is a shell-style script that looks `name` up on PATH itself (a pass-through wrapper, #4680). The lookup
271
+ * target may be the literal name, or (#5148) a variable/substitution when the script derives that name from `$0`
272
+ * (a `${0##…}` strip or `basename "$0"`), the shape of a wrapper symlinked under several command names.
273
+ */
270
274
  export function isPassThroughWrapper(text, name) {
271
275
  const t = String(text ?? "");
272
- return t.startsWith("#!") && t.includes(name) && /\b(command\s+-v|which|type\s+-p)\b/.test(t);
276
+ if (!t.startsWith("#!") || !/\b(command\s+-v|which|type\s+-p)\b/.test(t)) return false;
277
+ if (t.includes(name)) return true;
278
+ return /(?:command\s+-v|which|type\s+-p)\s+(?:-\s+)?["']?\$/.test(t) && /\$\{0##\*\/\}|basename\s+(?:--\s+)?["']?\$\{?0\b/.test(t);
273
279
  }
274
280
  const head = (p) => { try { return readFileSync(p, "utf8").slice(0, 4096); } catch { return ""; } };
275
281
 
@@ -332,6 +338,8 @@ export function findShimLoops(path, { names = HEAVY_BINS, find = which, read = h
332
338
  }
333
339
 
334
340
  export const SHIM_LOOP_MAX = 4;
341
+ /** #5148: hard bound on shim entries (FORGE_HEAVY_DEPTH) whatever the wrapper shape; far above legitimate `npm run` nesting. */
342
+ export const SHIM_DEPTH_MAX = 32;
335
343
  /**
336
344
  * Re-entry guard (#4680): FORGE_HEAVY_CHAIN lists `name:cmd` per shim run. A repeat of the same name+cmd where cmd is a
337
345
  * pass-through wrapper means it sent the call back to the shim; a repeat of a real binary is a legitimate nested call
@@ -342,15 +350,17 @@ export function guardChain(name, cmd, env, read = head) {
342
350
  const chain = String(env.FORGE_HEAVY_CHAIN ?? "").split("|").filter(Boolean);
343
351
  const dirs = chain.map((e) => dirname(e.slice(e.indexOf(":") + 1)));
344
352
  const loop = (why) => ({ error: `env.shim_loop: ${name} ${why} (PATH dirs: ${[env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean).join(", ") || "none"})` });
353
+ const depth = Math.max(0, Number.parseInt(env.FORGE_HEAVY_DEPTH, 10) || 0);
354
+ if (depth >= SHIM_DEPTH_MAX) return loop(`re-entered the heavy shim ${depth} levels deep (max ${SHIM_DEPTH_MAX})`);
345
355
  const repeat = chain.includes(`${name}:${cmd}`);
346
- if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env };
356
+ if (repeat && !isPassThroughWrapper(read(cmd), name)) return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1) } };
347
357
  if (chain.length >= SHIM_LOOP_MAX) return loop(`re-entered the heavy shim more than ${SHIM_LOOP_MAX} times`);
348
358
  if (repeat) {
349
359
  const next = which(name, env.PATH ?? "", [env.FORGE_HEAVY_SHIM_DIR, ...dirs].filter(Boolean));
350
360
  if (!next) return loop(`re-entered via ${cmd} and no other ${name} is on PATH`);
351
361
  cmd = next;
352
362
  }
353
- return { cmd, env: { ...env, FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
363
+ return { cmd, env: { ...env, FORGE_HEAVY_DEPTH: String(depth + 1), FORGE_HEAVY_CHAIN: [...chain, `${name}:${cmd}`].join("|") } };
354
364
  }
355
365
 
356
366
  /**
@@ -6,7 +6,7 @@ import { spawnSync } from "node:child_process";
6
6
  import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
9
+ import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, SHIM_DEPTH_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
10
10
  import { checkAdmission, ADMISSION_DEFAULTS, localLaneCap } from "./admission.mjs";
11
11
  import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
12
12
  import { readHeavy, readAdmission, liveLaneCap } from "../engine-cli.mjs";
@@ -347,3 +347,57 @@ describe("validation steps and the local lane cap (#4777)", () => {
347
347
  assert.equal(liveLaneCap(null, { cfg: readAdmission(d), host: { cores: () => 16, memAvailableMb: () => 64000 } })(), 2);
348
348
  });
349
349
  });
350
+
351
+ describe("#5148 dynamic-name pass-through wrapper", () => {
352
+ const WRAP = (log) => `#!/bin/bash\nname=\${0##*/}\necho w >> "${log}"\n[ "$(wc -l < "${log}")" -gt 12 ] && exit 99\nself_dir=$(cd "\${0%/*}" && pwd)\nreal=$(PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vxF "$self_dir" | paste -sd:) command -v "$name")\nexec "$real" "$@"\n`;
353
+ const fixture = () => {
354
+ const d = tmpDir("heavy-5148-"), wrap = join(d, "wrap"), real = join(d, "real"), bin = join(d, "bin"), log = join(d, "log");
355
+ for (const x of [wrap, real, bin]) mkdirSync(x, { recursive: true });
356
+ writeFileSync(log, "");
357
+ writeFileSync(join(wrap, "docker"), WRAP(log));
358
+ writeFileSync(join(real, "docker"), `#!/bin/sh\necho r >> ${log}\necho real-docker\n`);
359
+ for (const x of [wrap, real]) chmodSync(join(x, "docker"), 0o755);
360
+ return { d, wrap, real, bin, log };
361
+ };
362
+ const lines = (log) => readFileSync(log, "utf8").split("\n").filter(Boolean);
363
+
364
+ it("#5148: a ${0##*/} pass-through wrapper on PATH does not recurse through the heavy shim", async () => {
365
+ const { wrap, real, bin, log } = fixture();
366
+ assert.ok(isPassThroughWrapper(readFileSync(join(wrap, "docker"), "utf8"), "docker"));
367
+ assert.ok(isPassThroughWrapper('#!/bin/sh\nexec "$(command -v "$(basename "$0")")" "$@"', "docker"));
368
+ assert.ok(!isPassThroughWrapper("#!/bin/sh\necho hi\n", "docker"));
369
+ assert.ok(!isPassThroughWrapper('#!/bin/sh\nx=git\nexec "$(command -v "$x")" "$@"', "docker"), "dynamic lookup without a $0-derived name is not a wrapper");
370
+ assert.deepEqual(await writeHeavyShims(bin, { path: `${wrap}:${real}`, warn: () => {} }), ["docker"]);
371
+ assert.match(readFileSync(join(bin, "docker"), "utf8"), new RegExp(`-- '${real}/docker' "\\$@"`));
372
+ const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
373
+ const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
374
+ assert.equal(r.status, 0, r.stderr);
375
+ assert.match(r.stdout, /real-docker/);
376
+ assert.deepEqual(lines(log), ["r"], "the wrapper never ran; the real binary ran once");
377
+ });
378
+
379
+ it("#5148: with the shim baking the wrapper, the call reaches the real binary within SHIM_LOOP_MAX levels", () => {
380
+ const { wrap, real, bin, log } = fixture();
381
+ writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
382
+ const env = { ...process.env, PATH: `${bin}:${wrap}:${real}:/usr/bin:/bin`, FORGE_HEAVY_DISABLED: "1" };
383
+ const r = spawnSync(join(bin, "docker"), ["ps"], { env, encoding: "utf8", timeout: 20000 });
384
+ assert.equal(r.status, 0, r.stderr);
385
+ assert.ok(lines(log).length <= SHIM_LOOP_MAX + 1, lines(log).join(","));
386
+ assert.equal(lines(log).at(-1), "r");
387
+ });
388
+
389
+ it("#5148: guardChain stops at SHIM_DEPTH_MAX even for a repeat it judges legitimate, and counts each entry", () => {
390
+ const env = { PATH: "/s:/r", FORGE_HEAVY_SHIM_DIR: "/s", FORGE_HEAVY_CHAIN: "npm:/r/npm", FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX) };
391
+ assert.match(guardChain("npm", "/r/npm", env, () => "").error, /^env\.shim_loop: npm .*levels deep/);
392
+ const ok = guardChain("npm", "/r/npm", { ...env, FORGE_HEAVY_DEPTH: String(SHIM_DEPTH_MAX - 1) }, () => "");
393
+ assert.equal(ok.error, undefined); assert.equal(ok.env.FORGE_HEAVY_DEPTH, String(SHIM_DEPTH_MAX));
394
+ });
395
+
396
+ it("#5148: under systemd-run TasksMax the shimmed call finishes without hitting the limit", { skip: spawnSync("systemd-run", ["--user", "--scope", "-q", "true"], { timeout: 10000 }).status !== 0 && "no systemd --user manager" }, () => {
397
+ const { wrap, real, bin, log } = fixture();
398
+ writeFileSync(join(bin, "docker"), heavyShimScript("docker", join(wrap, "docker"))); chmodSync(join(bin, "docker"), 0o755);
399
+ const r = spawnSync("systemd-run", ["--user", "--scope", "-q", "-p", "TasksMax=64", "env", `PATH=${bin}:${wrap}:${real}:/usr/bin:/bin`, "FORGE_HEAVY_DISABLED=1", join(bin, "docker"), "ps"], { encoding: "utf8", timeout: 30000 });
400
+ assert.equal(r.status, 0, r.stderr);
401
+ assert.ok(lines(log).length < 13);
402
+ });
403
+ });