forgedock 1.37.0 → 1.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. package/scripts/review-pr-delta-wiring.test.sh +1 -1
@@ -6,7 +6,8 @@ import assert from "node:assert/strict";
6
6
  import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
- import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
9
+ import { renderContract } from "./render.mjs";
10
+ import { parseExecution, SANDBOX_PROVIDERS, detectNeeds, hostCapabilityBlock, placementText, contractHostNeeds, placement, placeLane, validationWeight, hostHeadroom, scopedEnv, providerLanes, isTransportError, containerProvider, providerByName, openExecutionBudget } from "./execution.mjs";
10
11
  import { makeLedger, countLiveLanes } from "./ledger.mjs";
11
12
  import { openBatch } from "./batch.mjs";
12
13
  import { orchestrate, formatTable, formatSandboxSummary } from "./orchestrate.mjs";
@@ -726,6 +727,68 @@ describe("host capabilities in placement (#4542)", () => {
726
727
  assert.deepEqual(hostCapabilityBlock("owner must create an account", caps), []);
727
728
  });
728
729
 
730
+ it("#4785 placementText: body, FORGE:DECISION comments and the last frozen contract's validate commands", () => {
731
+ const exec = parseExecution({ mode: "remote", max_remote: 2, host_capabilities: "promtool" });
732
+ const k = (cmd) => "<!-- FORGE:CONTRACT -->\n<!-- FORGE:PLAN -->\n" + renderContract({ requirements: [], deliverables: [], validate: [{ id: "V1", command: cmd }] }, "abcdef123456");
733
+ const comments = [{ body: "<!-- FORGE:DECISION -->\n## Decision\ngather the data via `ssh ovh-monitor obs-psql`" }, { body: k("node --test a.mjs") },
734
+ { body: k("promtool test rules x.yml") }, { body: "just chatter about sops" }, "not an object", null];
735
+ const t = placementText({ body: "add an alert", comments });
736
+ assert.deepEqual(detectNeeds(t, exec), ["prod_monitor", "promtool"]);
737
+ assert.doesNotMatch(t, /chatter|node --test a\.mjs/);
738
+ assert.deepEqual(detectNeeds("add an alert", exec), []); // the body alone: what placement read before #4785
739
+ assert.equal(placementText({ body: "b", comments: undefined }), "b");
740
+ assert.equal(placementText(), "");
741
+ });
742
+
743
+ it("#4785 contractHostNeeds: only declared capabilities in validate commands", () => {
744
+ const caps = { hostCapabilities: ["promtool"] };
745
+ const c = { validate: [{ id: "V1", command: "node --test a.mjs" }, { id: "V2", command: "promtool check rules r.yml" }, { id: "V3", command: "sops -d x.enc.yaml" }] };
746
+ assert.deepEqual(contractHostNeeds(c, caps), { caps: ["promtool"], ids: ["V2"] });
747
+ assert.deepEqual(contractHostNeeds(c, null), { caps: [], ids: [] });
748
+ assert.deepEqual(contractHostNeeds(null, caps), { caps: [], ids: [] });
749
+ });
750
+
751
+ it("#4776 placementText ignores quoted evidence in the body (AlterLab #37290-#37317 fixture)", () => {
752
+ const rows = JSON.parse(readFileSync(new URL("../tests/fixtures/alterlab-capability-bodies.json", import.meta.url), "utf8"));
753
+ assert.equal(rows.length, 6);
754
+ for (const r of rows) {
755
+ assert.deepEqual(detectNeeds(placementText({ body: r.body })), r.needs, `#${r.issue}: ${r.note}`);
756
+ if (!r.needs.length) assert.ok(detectNeeds(r.body).includes("prod_monitor"), `#${r.issue} pinned before #4776`);
757
+ }
758
+ });
759
+
760
+ it("#4776 placementText: fences, quotes, comments and evidence sections are dropped, prose and decisions stay", () => {
761
+ const body = "Intro.\n```sh\nssh ovh-monitor obs-psql\n```\n> ssh ovh-monitor\n<!-- FORGE:STATE\n{\"reason\":\"prod_monitor\"}\n-->\n## Logs\nobs-psql here\n### Sub\nobs-psql sub\n## Plan\nneeds sops access\n";
762
+ assert.deepEqual(detectNeeds(placementText({ body })), ["sops"]);
763
+ assert.deepEqual(detectNeeds(placementText({ body: "~~~\nobs-psql\n~~~\nrun `obs-psql`" })), []);
764
+ assert.deepEqual(detectNeeds(placementText({ body: "needs the prod monitor" })), ["prod_monitor"]);
765
+ assert.deepEqual(detectNeeds(placementText({ body: "x", comments: [{ body: "<!-- FORGE:DECISION -->\nuse `ssh ovh-monitor obs-psql`" }] })), ["prod_monitor"]);
766
+ });
767
+
768
+ it("#4776 repro_test commands count: placementText and contractHostNeeds", () => {
769
+ const exec = parseExecution({ mode: "remote", max_remote: 2, host_capabilities: "promtool" });
770
+ const caps = { hostCapabilities: ["promtool"] };
771
+ const c = { validate: [{ id: "V1", command: "node --test a.mjs" }], repro_test: { command: "promtool test rules x.yml" } };
772
+ assert.deepEqual(contractHostNeeds(c, caps), { caps: ["promtool"], ids: ["repro_test"] });
773
+ assert.deepEqual(contractHostNeeds({ validate: [], repro_test: "none" }, caps), { caps: [], ids: [] });
774
+ const comment = "<!-- FORGE:CONTRACT -->\n<!-- FORGE:PLAN -->\n" + renderContract({ requirements: [], deliverables: [], validate: [{ id: "V1", command: "node --test a.mjs" }], repro_test: { command: "promtool test rules x.yml" } }, "abcdef123456");
775
+ const t = placementText({ body: "b", comments: [{ body: comment }] });
776
+ assert.deepEqual(detectNeeds(t, exec), ["promtool"]);
777
+ });
778
+
779
+ it("#4785 orchestrate: a remote lane that ends env.tool_missing is relaunched locally", async () => {
780
+ const calls = [];
781
+ const rows = await orchestrate({ issues: [5], parallel: 2, recovery: { maxAttempts: 1 }, readBody: async () => "",
782
+ remote: { max: 2, isRemote: () => true, place: () => ({ where: "remote", reason: "mode remote" }), admit: () => ({ ok: true }), onBudget: "stop", costPerMinute: 0 },
783
+ runOne: async (n, _l, ctx = {}) => {
784
+ calls.push({ placement: ctx.placement?.where, reason: ctx.placement?.reason, recover: ctx.recover?.code ?? null });
785
+ return ctx.recover ? { terminalReason: "merged", pr: 9 } : { terminalReason: "needs-human", code: "env.tool_missing", detail: "promtool: command not found" };
786
+ } });
787
+ assert.deepEqual(calls.map((c) => [c.placement, c.recover]), [["remote", null], ["local", "env.tool_missing"]]);
788
+ assert.equal(calls[1].reason, "host capability: recovering env.tool_missing");
789
+ assert.equal(rows.find((r) => r.issue === 5).outcome, "merged");
790
+ });
791
+
729
792
  it("orchestrate: a remote lane that ends env.capability_remote is relaunched locally once", async () => {
730
793
  const calls = [];
731
794
  const rows = await orchestrate({ issues: [5], parallel: 2, recovery: { maxAttempts: 1 }, readBody: async () => "",
@@ -42,7 +42,7 @@ describe("explorer subagent config (#4391)", () => {
42
42
  const [plain, withAgents] = seen;
43
43
  assert.ok(!plain.includes("--agents"));
44
44
  const i = withAgents.indexOf("--agents");
45
- assert.deepEqual(withAgents.slice(0, i), plain);
45
+ assert.deepEqual([...withAgents.slice(0, i), ...withAgents.slice(i + 2)], plain, "only the --agents pair is added");
46
46
  assert.deepEqual(JSON.parse(withAgents[i + 1]), agents);
47
47
  });
48
48
 
@@ -13,6 +13,7 @@ import { homedir } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
  import { isTestPath } from "./enumerating.mjs";
15
15
  import { parseContractComment } from "./render.mjs";
16
+ import { isTrustedIssueComment } from "./prefilter.mjs";
16
17
 
17
18
  export const GUARD_MARKER = "FORGE:GUARD";
18
19
  const GUARD_RE = /<!-- FORGE:GUARD (\{[\s\S]*?\}) -->/;
@@ -98,7 +99,7 @@ export async function guardIssue({ gh, git, repo, issue, pr = null, post = null,
98
99
  const iss = json(await gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,comments"]), null);
99
100
  if (!iss) return { skipped: true, reason: `#${issue}: issue not readable` };
100
101
  const bodies = (iss.comments ?? []).map((c) => c?.body ?? "");
101
- const existing = bodies.map(parseGuard).find(Boolean);
102
+ const existing = (iss.comments ?? []).filter(isTrustedIssueComment).map((c) => parseGuard(c?.body)).find(Boolean); // #4750: only trusted authors may plant a guard
102
103
  if (existing) return { exists: true, guard: existing };
103
104
  const cb = [...bodies].reverse().find((b) => b.startsWith("<!-- FORGE:CONTRACT -->") && parseContractComment(b));
104
105
  if (!cb) return { skipped: true, reason: `no guard for #${issue}: no frozen FORGE:CONTRACT comment` };
@@ -126,7 +127,7 @@ export async function guardIssue({ gh, git, repo, issue, pr = null, post = null,
126
127
  export async function collectGuards({ gh, repo, limit = 500 }) {
127
128
  const rows = json(await gh(["issue", "list", "-R", repo, "--state", "all", "--search", `${GUARD_MARKER} in:comments`, "--limit", String(limit), "--json", "number,comments"]), []);
128
129
  const guards = [];
129
- for (const r of rows) for (const c of r.comments ?? []) { const g = hasMarker(c?.body) ? parseGuard(c.body) : null; if (g) { guards.push(g); break; } }
130
+ for (const r of rows) for (const c of r.comments ?? []) { const g = hasMarker(c?.body) && isTrustedIssueComment(c) ? parseGuard(c.body) : null; if (g) { guards.push(g); break; } }
130
131
  return guards;
131
132
  }
132
133
 
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import test from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { buildGuard, guardTests, parseHunks, renderGuard, parseGuard, guardIssue, listGuards, backfill, guardsCli, readIndex, guardAfterMerge, historyPreserve, withHistoryRows, writeIndex } from "./guards.mjs";
5
+ import { buildGuard, guardTests, parseHunks, renderGuard, parseGuard, guardIssue, collectGuards, listGuards, backfill, guardsCli, readIndex, guardAfterMerge, historyPreserve, withHistoryRows, writeIndex } from "./guards.mjs";
6
6
  import { renderContract } from "./render.mjs";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
 
@@ -55,7 +55,7 @@ function fakeGh({ contract, comments = [], title = "Fix the thing" }) {
55
55
  const posted = [];
56
56
  const body = `<!-- FORGE:CONTRACT -->\n${renderContract({ requirements: [], deliverables: [], preserve: [], validate: [], ...contract }, "abcdef123456")}`;
57
57
  const gh = async (a) => {
58
- if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ number: 7, title, comments: [{ body }, ...comments, ...posted.map((b) => ({ body: b }))] });
58
+ if (a[0] === "issue" && a[1] === "view") return JSON.stringify({ number: 7, title, comments: [{ body }, ...comments, ...posted.map((b) => ({ body: b, authorAssociation: "OWNER" }))] });
59
59
  if (a[0] === "pr" && a[1] === "view") return JSON.stringify({ number: 9, mergeCommit: { oid: "deadbeefcafe" } });
60
60
  if (a[0] === "issue" && a[1] === "comment") { posted.push(a[a.indexOf("--body") + 1]); return ""; }
61
61
  throw new Error(`unexpected gh ${a.join(" ")}`);
@@ -74,6 +74,27 @@ test("guardIssue posts exactly one FORGE:GUARD and is idempotent on rerun", asyn
74
74
  assert.equal(parseGuard(f.posted[0]).merge_sha, "deadbeefcafe");
75
75
  });
76
76
 
77
+ test("guardIssue ignores a planted FORGE:GUARD from an untrusted author and still posts the real one", async () => {
78
+ const planted = buildGuard({ issue: 7, title: "t", contract: nodeContract, diff: DIFF }).guard;
79
+ const f = fakeGh({ contract: nodeContract, comments: [{ body: renderGuard(planted), authorAssociation: "NONE", author: { login: "mallory" } }] });
80
+ assert.ok((await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 })).posted);
81
+ const t = fakeGh({ contract: nodeContract, comments: [{ body: renderGuard(planted), authorAssociation: "MEMBER" }] });
82
+ assert.ok((await guardIssue({ gh: t.gh, git, repo: "o/r", issue: 7, pr: 9 })).exists);
83
+ });
84
+
85
+ test("collectGuards ignores a FORGE:GUARD comment from an untrusted author", async () => {
86
+ const mk = (issue) => renderGuard(buildGuard({ issue, title: "t", contract: nodeContract, diff: DIFF }).guard);
87
+ const gh = async () => JSON.stringify([
88
+ { number: 1, comments: [{ body: mk(1), authorAssociation: "OWNER" }] },
89
+ { number: 2, comments: [{ body: mk(2), authorAssociation: "CONTRIBUTOR", author: { login: "mallory" } }] },
90
+ { number: 3, comments: [{ body: mk(3), authorAssociation: "NONE" }, { body: mk(3), authorAssociation: "MEMBER" }] },
91
+ ]);
92
+ assert.deepEqual((await collectGuards({ gh, repo: "o/r" })).map((g) => g.issue), [1, 3]);
93
+ const root = tmpDir("guards-");
94
+ await guardsCli(["list"], { gh, repo: "o/r", print: () => {}, root });
95
+ assert.deepEqual(readIndex("o/r", { root }).map((g) => g.issue), [1, 3]);
96
+ });
97
+
77
98
  test("guardIssue: no test posts nothing; unavailable merge commit is a logged skip", async () => {
78
99
  const f = fakeGh({ contract: { repro_test: "none", repro_test_reason: "n/a" } });
79
100
  assert.ok((await guardIssue({ gh: f.gh, git, repo: "o/r", issue: 7, pr: 9 })).skipped);
@@ -111,7 +132,7 @@ test("guards list prints guards for a path and caches the index; falls back to i
111
132
  const root = tmpDir("guards-");
112
133
  {
113
134
  const g = buildGuard({ issue: 7, title: "t", contract: nodeContract, diff: DIFF }).guard;
114
- const gh = async () => JSON.stringify([{ number: 7, comments: [{ body: renderGuard(g) }] }]);
135
+ const gh = async () => JSON.stringify([{ number: 7, comments: [{ body: renderGuard(g), authorAssociation: "OWNER" }] }]);
115
136
  const out = [];
116
137
  assert.equal(await guardsCli(["list", "--path", "src/a.mjs"], { gh, repo: "o/r", print: (m) => out.push(m), root }), 0);
117
138
  assert.match(out[0], /#7/);
@@ -36,15 +36,22 @@ const PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
36
36
  const PM_LIGHT = new Set(["version", "help", "view", "info", "show", "ls", "list", "why", "outdated", "config", "get", "set", "whoami", "root", "bin", "prefix", "store", "cache", "pack", "publish", "login", "logout", "search", "owner", "dist-tag"]);
37
37
  const PM_VALUE_FLAGS = new Set(["-w", "-C", "-F", "--filter", "--dir", "--cwd", "--prefix", "--workspace"]);
38
38
  const RUNNERS = new Set(["npx", "bunx", "pnpx"]);
39
- const TOOLS = new Set(["tsc", "eslint", "jest", "vitest", "playwright", "pytest", "mypy", "pyright", "tox", "gradle", "gradlew", "mvn"]);
39
+ const TOOLS = new Set(["tsc", "eslint", "jest", "vitest", "playwright", "pytest", "mypy", "pyright", "tox", "gradle", "gradlew", "mvn", "ruff", "black", "flake8", "pylint", "make", "cmake", "ninja"]);
40
40
  const SUBTOOLS = {
41
41
  cargo: new Set(["build", "b", "test", "t", "check", "c", "clippy", "run", "r", "bench", "doc", "install"]),
42
42
  go: new Set(["build", "test", "vet", "run", "install", "generate"]),
43
43
  docker: new Set(["run", "build", "compose", "buildx"]),
44
44
  "docker-compose": new Set(["up", "run", "build", "create", "start"]),
45
+ promtool: new Set(["check", "test"]),
46
+ pip: new Set(["install", "wheel", "download"]),
47
+ pip3: new Set(["install", "wheel", "download"]),
45
48
  };
49
+ /** #4777: Python project managers; `run <cmd>` takes the class of <cmd>, these subcommands install or build. */
50
+ const PY_MANAGERS = new Set(["poetry", "uv", "pipenv", "pdm", "hatch"]);
51
+ const PY_MGR_HEAVY = new Set(["install", "sync", "build", "add", "lock", "update"]);
52
+ const PY_SHIMS = ["python", "python3"];
46
53
  /** Every command name a heavy shim is written for (only those found on the lane PATH get one). */
47
- export const HEAVY_BINS = Object.freeze([...PMS, ...RUNNERS, ...TOOLS, ...Object.keys(SUBTOOLS)]);
54
+ export const HEAVY_BINS = Object.freeze([...PMS, ...RUNNERS, ...TOOLS, ...Object.keys(SUBTOOLS), ...PY_MANAGERS, ...PY_SHIMS]);
48
55
  const INFO = new Set(["-v", "-V", "--version", "version", "-h", "--help", "help"]);
49
56
 
50
57
  /** 'heavy' | 'llm' for one command line (`name` is the basename, `args` its argv). Unknown commands are llm. */
@@ -61,6 +68,12 @@ export function classifyStep(name, args = []) {
61
68
  return PM_LIGHT.has(sub) || INFO.has(sub) ? "llm" : "heavy";
62
69
  }
63
70
  if (RUNNERS.has(n) || TOOLS.has(n)) return a.length === 0 && RUNNERS.has(n) ? "llm" : onlyInfo ? "llm" : "heavy";
71
+ if (PY_MANAGERS.has(n)) {
72
+ const i = a.findIndex((x) => !x.startsWith("-")), sub = a[i];
73
+ if (sub === "run") { const inner = a.slice(i + 1).filter((x, j, all) => !(j === 0 && x === "--") || all.length === 1); return inner.length ? (classifyStep(inner[0], inner.slice(1)) === "heavy" || isHeavyProcess(inner.join(" ")) ? "heavy" : "llm") : "llm"; }
74
+ return PY_MGR_HEAVY.has(sub) ? "heavy" : "llm";
75
+ }
76
+ if (PY_SHIMS.includes(n)) { const mi = a.indexOf("-m"); return mi >= 0 && PY_HEAVY_MODULES.has(a[mi + 1]) ? "heavy" : "llm"; }
64
77
  if (SUBTOOLS[n]) { const sub = a.find((x) => !x.startsWith("-")); return sub && SUBTOOLS[n].has(sub) ? "heavy" : "llm"; }
65
78
  return "llm";
66
79
  }
@@ -7,9 +7,9 @@ import { writeFileSync, readFileSync, mkdirSync, chmodSync, existsSync } from "n
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
10
- import { checkAdmission, ADMISSION_DEFAULTS } from "./admission.mjs";
10
+ import { checkAdmission, ADMISSION_DEFAULTS, localLaneCap } from "./admission.mjs";
11
11
  import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
12
- import { readHeavy } from "../engine-cli.mjs";
12
+ import { readHeavy, liveLaneCap } from "../engine-cli.mjs";
13
13
 
14
14
  // Injected pids, clock and host: `live` is the set of pids the registry sees as alive; `mem` the MemAvailable in MB.
15
15
  const world = ({ mem = 12000, cores = 4, swapFree = 0 } = {}) => {
@@ -308,3 +308,32 @@ describe("shim re-entry through a pass-through wrapper (#4680)", () => {
308
308
  assert.ok(!existsSync(join(bin, "docker")));
309
309
  });
310
310
  });
311
+
312
+ describe("validation steps and the local lane cap (#4777)", () => {
313
+ it("python project managers, promtool, pip, make and python -m <test runner> are heavy; metadata and plain scripts are llm", () => {
314
+ const heavy = [["poetry", ["run", "pytest", "-q"]], ["poetry", ["run", "python", "-m", "pytest"]], ["uv", ["run", "--", "pytest"]], ["poetry", ["install"]], ["uv", ["sync"]],
315
+ ["promtool", ["test", "rules", "r.yml"]], ["promtool", ["check", "config", "p.yml"]], ["pip", ["install", "x"]], ["python3", ["-m", "pytest"]], ["ruff", ["check", "."]], ["make", ["test"]]];
316
+ for (const [n, a] of heavy) assert.equal(classifyStep(n, a), "heavy", `${n} ${a.join(" ")}`);
317
+ const llm = [["poetry", ["--version"]], ["poetry", ["run", "python", "x.py"]], ["poetry", ["show"]], ["promtool", ["query", "instant", "u", "q"]], ["python", ["x.py"]], ["python3", ["-c", "print(1)"]], ["make", ["--version"]]];
318
+ for (const [n, a] of llm) assert.equal(classifyStep(n, a), "llm", `${n} ${a.join(" ")}`);
319
+ for (const n of ["poetry", "uv", "promtool", "pip", "python3", "make"]) assert.ok(HEAVY_BINS.includes(n), n);
320
+ });
321
+
322
+ it("localLaneCap follows cores and free memory, keeps at least one lane, and is off when admission is disabled or probes are unknown", () => {
323
+ assert.equal(localLaneCap({ cores: 4, memAvailableMb: 64000 }), 4);
324
+ assert.equal(localLaneCap({ cores: 4, memAvailableMb: 64000, cfg: { lane_cpu_reservation: 2 } }), 2);
325
+ assert.equal(localLaneCap({ cores: 16, memAvailableMb: 2048 + 3 * 2048 + 10 }), 3, "memory binds: (free - min_free_mem_mb) / lane_mem_mb");
326
+ assert.equal(localLaneCap({ cores: 4, memAvailableMb: 100 }), 1);
327
+ assert.equal(localLaneCap({ cores: 4, memAvailableMb: 64000, cfg: { enabled: false } }), null);
328
+ assert.equal(localLaneCap({}), null);
329
+ assert.equal(localLaneCap({ cores: 8 }), 8);
330
+ });
331
+
332
+ it("liveLaneCap re-probes at each call and honours global_parallel", () => {
333
+ const h = { c: 4, m: 64000 };
334
+ const cap = liveLaneCap(null, { cfg: {}, host: { cores: () => h.c, memAvailableMb: () => h.m } });
335
+ assert.equal(cap(), 4); h.c = 2; assert.equal(cap(), 2);
336
+ assert.equal(liveLaneCap(1, { cfg: {}, host: { cores: () => 8, memAvailableMb: () => 64000 } })(), 1);
337
+ assert.equal(liveLaneCap(3, { cfg: { enabled: false }, host: { cores: () => 8, memAvailableMb: () => 64000 } })(), 3);
338
+ });
339
+ });
@@ -0,0 +1,245 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * `mode: release` of `forgedock issue-watch` (#4767): pool eligible issues, cut a themed release when a threshold is crossed,
5
+ * orchestrate it as one batch and open the deploy PR. Deterministic first: score, triggers, theme rules, the `Depends on`
6
+ * closure and the cap are pure functions; the only judgment is one injected `io.classify` call for issues no `theme_map` rule
7
+ * classifies (counted in `state.llm`). The watch never merges: the deploy PR is only created, promotion stays human-only.
8
+ */
9
+ import { milestoneSlug } from "./lane.mjs";
10
+ import { noComments } from "./render.mjs";
11
+
12
+ export const PRIORITY_WEIGHT = { P0: 8, P1: 4, P2: 2, P3: 1 };
13
+ export const GENERAL_THEME = "general";
14
+ const DAY = 86400000;
15
+ const labelNames = (i) => (i.labels ?? []).map((l) => (typeof l === "string" ? l : l?.name)).filter(Boolean);
16
+
17
+ /** `P0`..`P3` from a `P1` or `priority:P1` label; null when none. */
18
+ export function priorityOf(issue) {
19
+ for (const l of labelNames(issue)) { const m = /^(?:priority[:/ -]?)?(P[0-3])$/i.exec(l); if (m) return m[1].toUpperCase(); }
20
+ return null;
21
+ }
22
+ /** Priority weight; an unprioritised issue weighs 1 (P3). */
23
+ export const weightOf = (issue) => PRIORITY_WEIGHT[priorityOf(issue)] ?? 1;
24
+ export const ageDays = (issue, now) => Math.max(0, (now - Date.parse(issue.createdAt)) / DAY);
25
+ export const poolScore = (issues) => issues.reduce((s, i) => s + weightOf(i), 0);
26
+
27
+ /** True when `issue` matches an `immediate` entry: `label:<name>` or `priority:<P0..P3>`. */
28
+ export function isImmediate(issue, immediate = []) {
29
+ const names = labelNames(issue).map((l) => l.toLowerCase());
30
+ return immediate.some((r) => { const [k, ...v] = String(r).split(":"), val = v.join(":").trim().toLowerCase(); return k === "label" ? names.includes(val) : k === "priority" ? priorityOf(issue)?.toLowerCase() === val : false; });
31
+ }
32
+
33
+ /** Every trigger that fires for the pool: `[{kind, detail}]` (min_issues, min_score, max_age_days, immediate). */
34
+ export function triggers(pool, watch, now) {
35
+ const t = watch.threshold ?? {}, out = [];
36
+ if (!pool.length) return out;
37
+ if (t.min_issues && pool.length >= t.min_issues) out.push({ kind: "min_issues", detail: `${pool.length} >= ${t.min_issues}` });
38
+ const score = poolScore(pool);
39
+ if (t.min_score && score >= t.min_score) out.push({ kind: "min_score", detail: `score ${score} >= ${t.min_score}` });
40
+ const oldest = Math.max(...pool.map((i) => ageDays(i, now)));
41
+ if (t.max_age_days && oldest >= t.max_age_days) out.push({ kind: "max_age_days", detail: `oldest ${oldest.toFixed(1)}d >= ${t.max_age_days}d` });
42
+ const imm = pool.filter((i) => isImmediate(i, watch.immediate));
43
+ if (imm.length) out.push({ kind: "immediate", detail: `#${imm.map((i) => i.number).join(", #")}` });
44
+ return out;
45
+ }
46
+
47
+ // ── themes ──────────────────────────────────────────────────────────────────────────────────────────────────
48
+ /** The conventional-commit scope of a title: `feat(security): x` → `security`. */
49
+ export const titleScope = (title) => /^\w+\(([^)]+)\)!?:/.exec(String(title ?? ""))?.[1].trim().toLowerCase() ?? null;
50
+ const globRe = (g) => new RegExp(`^${g.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\u0000").replace(/\*\*/g, "\u0001").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, "(?:.*/)?").replace(/\u0001/g, ".*")}$`);
51
+ /** Paths an issue names: slash-containing tokens of title and body (backticks, quotes and trailing punctuation stripped). */
52
+ export const pathsNamed = (issue) => [...new Set(noComments(`${issue.title ?? ""}\n${issue.body ?? ""}`).split(/[\s`'"()<>,;]+/).map((t) => t.replace(/^\.\//, "").replace(/[.:]+$/, "")).filter((t) => /^[\w.@-][\w.@*-]*(\/[\w.@*-]+)+\/?$/.test(t) && !/^https?:/.test(t)))];
53
+
54
+ /** Theme of an issue from `theme_map` rules (`label:`, `path:` glob, `scope:`; the first theme in config order wins), else a title scope equal to a theme name; null when no rule classifies it. */
55
+ export function classifyTheme(issue, themeMap = {}) {
56
+ const names = labelNames(issue).map((l) => l.toLowerCase()), scope = titleScope(issue.title), paths = pathsNamed(issue);
57
+ for (const [theme, rules] of Object.entries(themeMap)) {
58
+ for (const r of [].concat(rules)) {
59
+ const [k, ...v] = String(r).split(":"), val = v.join(":").trim();
60
+ if (k === "label" && names.includes(val.toLowerCase())) return { theme, via: r };
61
+ if (k === "scope" && scope === val.toLowerCase()) return { theme, via: r };
62
+ if (k === "path" && paths.some((p) => globRe(val).test(p))) return { theme, via: r };
63
+ }
64
+ }
65
+ const byScope = Object.keys(themeMap).find((t) => t.toLowerCase() === scope);
66
+ return byScope ? { theme: byScope, via: `scope:${scope}` } : null;
67
+ }
68
+
69
+ /**
70
+ * Classify the pool: rules first, then one `io.classify(issues, themes) → {number: theme}` call for the rest (counted).
71
+ * `cache` (state.themes) keeps earlier answers so a rolled-over issue costs no second call. Returns `{themes: {n: {theme, via}}, llm}`.
72
+ */
73
+ export async function classifyPool(pool, watch, { classify, cache = {} } = {}) {
74
+ const themes = {}, rest = [];
75
+ for (const i of pool) {
76
+ const c = classifyTheme(i, watch.theme_map);
77
+ if (c) themes[i.number] = c; else if (cache[i.number]) themes[i.number] = cache[i.number]; else rest.push(i);
78
+ }
79
+ let llm = 0;
80
+ if (rest.length) {
81
+ let ans = null;
82
+ if (classify) { try { ans = await classify(rest, Object.keys(watch.theme_map ?? {})); llm = rest.length; } catch { ans = null; } }
83
+ for (const i of rest) { const t = ans?.[i.number]; themes[i.number] = Object.hasOwn(watch.theme_map ?? {}, t) ? { theme: t, via: "llm" } : { theme: GENERAL_THEME, via: ans ? "llm" : "unclassified" }; }
84
+ }
85
+ return { themes, llm };
86
+ }
87
+
88
+ /** The ordered `Release priorities:` list of a NORTHSTAR text (inline `a, b` or following `- a` bullets), lower-cased; [] when absent. */
89
+ export function releasePriorities(text) {
90
+ const lines = String(text ?? "").split(/\r?\n/), at = lines.findIndex((l) => /^\s*(?:[-*]\s*)?\**release priorities\**:/i.test(l));
91
+ if (at < 0) return [];
92
+ const inline = lines[at].replace(/^[^:]*:\**/, "").trim();
93
+ if (inline) return inline.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
94
+ const out = [];
95
+ for (const l of lines.slice(at + 1)) { const m = /^\s*(?:[-*]|\d+\.)\s+`?([\w./-]+)`?/.exec(l); if (!m) break; out.push(m[1].toLowerCase()); }
96
+ return out;
97
+ }
98
+
99
+ /** Theme scores, best first: Σ priority weight + age (0.1/day, max 3 per issue) + a NORTHSTAR rank bonus; ties by priorities order then name. */
100
+ export function scoreThemes(pool, themes, now, priorities = []) {
101
+ const by = {};
102
+ for (const i of pool) (by[themes[i.number].theme] ??= []).push(i);
103
+ return Object.entries(by).map(([theme, issues]) => {
104
+ const idx = priorities.indexOf(theme.toLowerCase());
105
+ const score = Math.round(poolScore(issues) + issues.reduce((s, i) => s + Math.min(3, ageDays(i, now) * 0.1), 0) + (idx >= 0 ? priorities.length - idx : 0));
106
+ return { theme, count: issues.length, score, rank: idx >= 0 ? idx : Infinity };
107
+ }).sort((a, b) => b.score - a.score || a.rank - b.rank || a.theme.localeCompare(b.theme));
108
+ }
109
+
110
+ // ── the release set ─────────────────────────────────────────────────────────────────────────────────────────
111
+ const depsOf = (i) => [...String(i.body ?? "").matchAll(/^\s*(?:[-*]\s*)?\**depends on\**:?\s*(.+)$/gim)].flatMap((m) => [...m[1].matchAll(/#(\d+)/g)].map((x) => +x[1])).filter((n) => n !== i.number);
112
+
113
+ /** `issue` plus every open prerequisite, transitively (`openByNum`: all open issues); closed or unknown prerequisites are not part of it. */
114
+ export function closure(issue, openByNum) {
115
+ const out = new Map();
116
+ const visit = (i) => { if (out.has(i.number)) return; out.set(i.number, i); for (const d of depsOf(i)) if (openByNum.has(d)) visit(openByNum.get(d)); };
117
+ visit(issue);
118
+ return [...out.values()];
119
+ }
120
+
121
+ /**
122
+ * The release of `theme`: its issues (immediate first, then priority, then age) each with its `Depends on` closure, in order, while
123
+ * the set fits `max`; an issue whose closure does not fit stays out (rolls over). Prerequisites from another theme are marked `pulled`.
124
+ * Returns `{set: [issue], pulled: [{number, theme, for}], excluded: [{number, reason}]}`.
125
+ */
126
+ export function pickRelease(theme, pool, themes, openByNum, watch, now) {
127
+ const max = watch.max_release_issues ?? 40;
128
+ const mine = pool.filter((i) => themes[i.number].theme === theme)
129
+ .sort((a, b) => (isImmediate(b, watch.immediate) - isImmediate(a, watch.immediate)) || weightOf(b) - weightOf(a) || ageDays(b, now) - ageDays(a, now) || a.number - b.number);
130
+ const set = new Map(), pulled = [], excluded = [];
131
+ for (const i of mine) {
132
+ if (set.has(i.number)) continue;
133
+ const add = closure(i, openByNum).filter((x) => !set.has(x.number));
134
+ if (set.size + add.length > max) { excluded.push({ number: i.number, reason: `release cap ${max}` }); continue; }
135
+ for (const x of add) { set.set(x.number, x); if (themes[x.number]?.theme !== theme && !mine.includes(x)) pulled.push({ number: x.number, theme: themes[x.number]?.theme ?? classifyTheme(x, watch.theme_map)?.theme ?? "outside pool", for: i.number }); }
136
+ }
137
+ for (const i of pool) if (!set.has(i.number) && !excluded.some((e) => e.number === i.number)) excluded.push({ number: i.number, reason: themes[i.number].theme === theme ? "release cap" : `theme ${themes[i.number].theme}` });
138
+ return { set: [...set.values()], pulled, excluded };
139
+ }
140
+
141
+ /**
142
+ * The decision for a pool: `{fired, ranking, theme, release, forced, below}`. `release` is null (nothing cut) when no trigger fires.
143
+ * An immediate trigger forces the theme of its highest-priority issue and ships only that theme (plus its prerequisites).
144
+ */
145
+ export function decideRelease({ pool, openByNum, themes, watch, now, priorities = [] }) {
146
+ const fired = triggers(pool, watch, now), ranking = pool.length ? scoreThemes(pool, themes, now, priorities) : [];
147
+ const below = ranking.filter((r) => r.count < (watch.threshold?.min_issues ?? Infinity)).map((r) => `${r.theme} pool ${r.count} < threshold`);
148
+ if (!fired.length) return { fired, ranking, theme: null, release: null, forced: false, below };
149
+ const imm = pool.filter((i) => isImmediate(i, watch.immediate)).sort((a, b) => weightOf(b) - weightOf(a) || a.number - b.number);
150
+ const forced = imm.length > 0, theme = forced ? themes[imm[0].number].theme : ranking[0].theme;
151
+ return { fired, ranking, theme, release: pickRelease(theme, pool, themes, openByNum, watch, now), forced, below: below.filter((b) => !b.startsWith(`${theme} `)) };
152
+ }
153
+
154
+ /** `would cut: stealth (14 issues, score 46; security pool 1 < threshold)` plus triggers and per-issue theme lines. */
155
+ export function formatDryRun(d, pool, themes) {
156
+ const lines = [];
157
+ if (!d.release) lines.push(`would not cut: ${pool.length} pooled, no trigger fired`);
158
+ else {
159
+ const sc = d.ranking.find((r) => r.theme === d.theme)?.score;
160
+ lines.push(`would cut: ${d.theme} (${d.release.set.length} issues, score ${sc}${d.below.length ? `; ${d.below.join("; ")}` : ""})`);
161
+ lines.push(` triggers: ${d.fired.map((f) => `${f.kind} (${f.detail})`).join(", ")}${d.forced ? " [immediate: theme forced]" : ""}`);
162
+ lines.push(` set: ${d.release.set.map((i) => `#${i.number}`).join(" ")}`);
163
+ if (d.release.pulled.length) lines.push(` pulled prerequisites: ${d.release.pulled.map((p) => `#${p.number} (${p.theme}, for #${p.for})`).join(", ")}`);
164
+ }
165
+ lines.push(" theme scores: " + (d.ranking.map((r) => `${r.theme}=${r.score} (${r.count})`).join(", ") || "(empty pool)"));
166
+ for (const i of pool) lines.push(` #${i.number} ${themes[i.number].theme} [${themes[i.number].via}]${d.release?.set.some((s) => s.number === i.number) ? "" : " (stays pooled)"}`);
167
+ return lines.join("\n");
168
+ }
169
+
170
+ /** The trusted `FORGE:RELEASE_PLAN` comment: theme, reasons, included and excluded issues, cited policy. */
171
+ export function releasePlanComment({ theme, tag, decision, policy, now }) {
172
+ const r = decision.release, data = { v: 1, theme, tag, at: new Date(now).toISOString(), included: r.set.map((i) => i.number), pulled: r.pulled, excluded: r.excluded, triggers: decision.fired, policy: policy ?? null };
173
+ return [`<!-- FORGE:RELEASE_PLAN ${JSON.stringify(data)} -->`, `## Release plan: ${theme} (${tag})`,
174
+ `**Why:** ${decision.fired.map((f) => `${f.kind} (${f.detail})`).join(", ")}${decision.forced ? "; immediate trigger, theme forced" : ""}`,
175
+ `**Policy:** ${policy ?? "none (score, then theme name)"}`,
176
+ `**Included:** ${r.set.map((i) => `#${i.number}`).join(" ")}`,
177
+ ...(r.pulled.length ? [`**Pulled prerequisites:** ${r.pulled.map((p) => `#${p.number} (${p.theme}, for #${p.for})`).join(", ")}`] : []),
178
+ `**Excluded (stay pooled):** ${r.excluded.map((e) => `#${e.number} (${e.reason})`).join(", ") || "none"}`,
179
+ "Promotion to the default branch stays human-only; the watch opens the deploy PR and never merges it."].join("\n");
180
+ }
181
+
182
+ // ── the tick ─────────────────────────────────────────────────────────────────────────────────────────────────
183
+ const isoDate = (ms) => new Date(ms).toISOString().slice(0, 10);
184
+
185
+ /**
186
+ * One release-mode pass. Settles the active release (opens the deploy PR once its batch reports), else pools, decides and cuts.
187
+ * `listOpen()→[issue]` all open issues, `eligible(issue)` selector + trust + unclaimed, `claim(n)→bool` repo-wide claim,
188
+ * `laneFor(issue)`, `dagOrder(issues)`, `policy {path,text}`. `dryRun` writes nothing and returns `{state, decision, pool, themes}`.
189
+ */
190
+ export async function releaseTick({ watch, repo, io, state, defaults = [], listOpen, eligible, claim, laneFor, dagOrder, policy = {}, dryRun = false }) {
191
+ const now = io.now(), gh = async (a) => String(await io.gh(a));
192
+ state.started ??= now;
193
+ const rel = state.release;
194
+ if (rel && !dryRun) {
195
+ for (const n of [...state.inflight]) if (JSON.parse(await gh(["issue", "view", String(n), "-R", repo, "--json", "state"])).state === "CLOSED") { state.inflight = state.inflight.filter((x) => x !== n); state.merged.push(n); state.verified.push(n); }
196
+ if (!io.batch.reported(rel.batch)) return state;
197
+ state.inflight.forEach((n) => state.failed.push(n)); state.inflight = [];
198
+ if (!rel.deployPr) {
199
+ const base = defaults[0];
200
+ if (!base) throw new Error("no default branch known; cannot open the deploy PR");
201
+ const open = JSON.parse(await gh(["pr", "list", "-R", repo, "--head", rel.lane, "--base", base, "--state", "open", "--json", "number"]));
202
+ rel.deployPr = open[0]?.number ?? (+(/(\d+)\s*$/.exec(await gh(["pr", "create", "-R", repo, "--base", base, "--head", rel.lane, "--title", `release(${rel.theme}): ${rel.tag}`,
203
+ "--body", `Themed release **${rel.theme}** (${rel.tag}).\n\nIssues: ${rel.issues.map((n) => `#${n}`).join(" ")}\nMerged: ${state.merged.filter((n) => rel.issues.includes(n)).map((n) => `#${n}`).join(" ") || "none"}\nFailed: ${state.failed.filter((n) => rel.issues.includes(n)).map((n) => `#${n}`).join(" ") || "none"}\n\nPromotion is human-only: the issue watch opened this PR and never merges it.`]))?.[1]) || true);
204
+ }
205
+ (state.releases ??= []).push(rel); state.release = null;
206
+ }
207
+ if (state.release && dryRun) return { state, decision: null, pool: [], themes: {} };
208
+ const capped = (watch.max_issues && state.claimed.length >= watch.max_issues) || (watch.until && now >= Date.parse(watch.until));
209
+ if (capped && !dryRun) { state.phase = "done"; return state; }
210
+
211
+ const open = await listOpen(), openByNum = new Map(open.map((i) => [i.number, i]));
212
+ const pool = open.filter(eligible).sort((a, b) => a.number - b.number);
213
+ const { themes, llm } = await classifyPool(pool, watch, { classify: io.classify, cache: state.themes });
214
+ const decision = decideRelease({ pool, openByNum, themes, watch, now, priorities: releasePriorities(policy.text) });
215
+ if (dryRun) return { state, decision, pool, themes };
216
+ state.llm = (state.llm ?? 0) + llm;
217
+ state.themes = { ...(state.themes ?? {}), ...Object.fromEntries(Object.entries(themes).filter(([, v]) => v.via === "llm").map(([n, v]) => [n, v])) };
218
+ state.pool = pool.map((i) => i.number);
219
+ if (!decision.release?.set.length) return state;
220
+
221
+ const taken = [];
222
+ // Only pool members are claimed: a Depends-on prerequisite that never passed `eligible` (an outsider's issue, an excluded label)
223
+ // is not launched by this watch; its dependent waits on the edge in the batch (release #4820 SPEC-2).
224
+ const inPool = new Set(pool.map((i) => i.number));
225
+ for (const i of dagOrder(decision.release.set)) { if (!inPool.has(i.number)) continue; if (watch.max_issues && state.claimed.length + taken.length >= watch.max_issues) break; if (claim(i.number)) taken.push(i); }
226
+ if (!taken.length) return state;
227
+ const lane = taken.map((i) => laneFor(i))[0], tag = `${milestoneSlug(decision.theme) || decision.theme}-${isoDate(now)}`, nums = taken.map((i) => i.number);
228
+ const plan = releasePlanComment({ theme: decision.theme, tag, decision: { ...decision, release: { ...decision.release, set: taken } }, policy: policy.path, now });
229
+ const tracker = watch.tracker ?? nums[0];
230
+ await gh(["issue", "comment", String(tracker), "-R", repo, "--body", plan]);
231
+ if (watch.milestone && watch.milestone !== "none") {
232
+ const title = `release/${tag}`;
233
+ // A second cut of the same theme on the same day reuses the milestone: GitHub answers 422 already_exists (release #4820 CONC-1).
234
+ try { await gh(["api", `repos/${repo}/milestones`, "-f", `title=${title}`]); } catch (e) { if (!/already_exists|HTTP 422/i.test(String(e?.stderr ?? e?.message ?? e))) throw e; }
235
+ for (const n of nums) await gh(["issue", "edit", String(n), "-R", repo, "--milestone", title]);
236
+ } else {
237
+ const label = `release:${tag}`;
238
+ await gh(["label", "create", label, "-R", repo, "--force"]);
239
+ for (const n of nums) await gh(["issue", "edit", String(n), "-R", repo, "--add-label", label]);
240
+ }
241
+ for (const i of taken) { state.claimed.push(i.number); state.inflight.push(i.number); state.lanes[i.number] = lane; }
242
+ const batch = await io.batch.start(nums, watch.parallel, lane);
243
+ state.release = { theme: decision.theme, tag, lane, batch, issues: nums, tracker, at: now, deployPr: null };
244
+ return state;
245
+ }