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
@@ -391,6 +391,21 @@ describe("core work-on", () => {
391
391
  assert.doesNotMatch(p, /complete what is missing/);
392
392
  });
393
393
 
394
+ it("#4786: a remote lane branch holding only .forge-lean scratch plans fresh and drops the scratch", async () => {
395
+ const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", changed: [".forge-lean/contract.json", ".forge-lean/checkpoint.json"] });
396
+ await run(w);
397
+ assert.doesNotMatch(builders(w)[0][4], /This is a rerun/);
398
+ assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "rm" && c.includes(".forge-lean")), "scratch removed from the branch");
399
+ assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "commit" && c.join(" ").includes("drop stale .forge-lean scratch")));
400
+ });
401
+
402
+ it("#4786: a remote lane branch with a code change besides scratch still resumes", async () => {
403
+ const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", changed: [".forge-lean/contract.json", "src/foo.js"] });
404
+ await run(w);
405
+ assert.match(builders(w)[0][4], /This is a rerun/);
406
+ assert.ok(!w.calls.some((c) => c[0] === "git" && c[1] === "rm" && c.includes(".forge-lean")));
407
+ });
408
+
394
409
  it("a reviewer that returns no JSON twice ends review.no_verdict", async () => {
395
410
  const w = world({ reviews: ["no json", "still none"] });
396
411
  const r = await run(w);
@@ -419,6 +434,54 @@ describe("core work-on", () => {
419
434
  assert.ok(!w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN")));
420
435
  };
421
436
 
437
+ // #4730: owner step + agent-doable remainder, and duplicate owner asks
438
+ const ownerWorld = (block, o = {}, existing = null) => {
439
+ const w = depWorld({ changed: [], result: { status: "blocked", summary: "sign the contract", block }, ...o }, {});
440
+ const gh = w.io.gh;
441
+ w.io.gh = async (a) => {
442
+ if (a[0] === "issue" && a[1] === "list" && existing && a.some((x) => String(x).includes("FORGE:ASK"))) return JSON.stringify([existing]);
443
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(w.listed ?? []);
444
+ return gh(a);
445
+ };
446
+ return w;
447
+ };
448
+ it("#4730 a human block with a remainder re-queues it as its own issue; only the owner step keeps needs-human", async () => {
449
+ const w = ownerWorld({ kind: "human", why: "credentials", remainder: [{ title: "wire the client", body: "do the code part" }, { title: "", body: "dropped" }] });
450
+ const r = await run(w);
451
+ assert.equal(r.terminalReason, "needs-human");
452
+ assert.equal(w.created.length, 1);
453
+ const body = w.created[0][w.created[0].indexOf("--body") + 1];
454
+ assert.match(body, /\*\*Parent\*\*: #7/); assert.match(body, /FORGE:REMAINDER of=#7/);
455
+ assert.ok(!w.created[0].includes("needs-human"));
456
+ assert.ok(w.edits.some((e) => e.includes("--add-label") && e.includes("needs-human")));
457
+ assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("re-queued as #201")));
458
+ });
459
+ it("#4730 a rerun reuses the remainder issue it already filed", async () => {
460
+ const w = ownerWorld({ kind: "human", why: "credentials", remainder: [{ title: "wire the client", body: "x" }] });
461
+ const key = (await import("./needs-human-premise.mjs")).askKey("wire the client");
462
+ w.listed = [{ number: 150, body: `x\n<!-- FORGE:REMAINDER of=#7 key=${key} -->` }];
463
+ await run(w);
464
+ assert.equal(w.created.length, 0);
465
+ assert.ok(w.comments.some((c) => c.includes("re-queued as #150")));
466
+ });
467
+ it("#4730 a human block without a remainder files nothing", async () => {
468
+ const w = ownerWorld({ kind: "human", why: "credentials" });
469
+ assert.equal((await run(w)).terminalReason, "needs-human");
470
+ assert.equal(w.created.length, 0);
471
+ });
472
+ it("#4730 an issue whose only content is an existing owner ask is closed as a duplicate; extra content only links", async () => {
473
+ const { askKey } = await import("./needs-human-premise.mjs");
474
+ const existing = { number: 40, comments: [{ author: { login: "rapiercraft-forgedock" }, body: `<!-- FORGE:ASK key=${askKey("Builder is blocked (credentials): sign the contract")} -->\n## Owner ask\n{}` }] };
475
+ const dup = ownerWorld({ kind: "human", why: "credentials" }, { body: "sign the contract" }, existing);
476
+ const r = await run(dup);
477
+ assert.equal(r.terminalReason, "invalid");
478
+ assert.ok(dup.closed.some((c) => c.includes("not planned") && c.join(" ").includes("#40")));
479
+ noNeedsHuman(dup);
480
+ const more = ownerWorld({ kind: "human", why: "credentials" }, { body: "sign the contract and then also rework the importer" }, existing);
481
+ assert.equal((await run(more)).terminalReason, "needs-human");
482
+ assert.equal(more.closed.length, 0);
483
+ });
484
+
422
485
  it("#4124 a builder block naming an open issue ends dependency-open with dependencies, not needs-human", async () => {
423
486
  const w = depWorld({ changed: [], result: { status: "blocked", summary: "needs #34927 first", block: { kind: "dependency", issues: ["#3", 7] } } },
424
487
  { 3: { state: "OPEN", labels: [] } });
@@ -659,6 +722,30 @@ describe("core work-on", () => {
659
722
  assert.equal(w2.calls.filter((c) => c[1] === "worktree" && c[2] === "add").length, 5);
660
723
  });
661
724
 
725
+ it("#4785 a remote lane whose frozen validate commands name a declared host capability ends env.capability_remote before building", async () => {
726
+ const contract = { ...CONTRACT, validate: [...CONTRACT.validate, { id: "V2", command: "promtool test rules r.yml" }] };
727
+ const remote = { placement: { where: "remote", reason: "mode remote" }, hostCaps: { hostCapabilities: ["promtool"] } };
728
+ const dir = tmpDir("lean-4785-");
729
+ const w = world({ contract });
730
+ const r = await run(w, { dir, ...remote });
731
+ assert.equal(r.terminalReason, "needs-human");
732
+ assert.equal(r.code, "env.capability_remote");
733
+ assert.equal(builders(w).length, 1); // the plan session only: no implementation turn
734
+ assert.match(w.comments.map((c) => c.body ?? c).join("\n"), /promtool[\s\S]*V2/);
735
+ // local placement, no declared capability, or a recovery run: unchanged
736
+ assert.equal((await run(world({ contract }), { ...remote, placement: { where: "local", reason: "x" } })).terminalReason, "merged");
737
+ assert.equal((await run(world({ contract }), { ...remote, hostCaps: { hostCapabilities: ["sops"] } })).terminalReason, "merged");
738
+ });
739
+
740
+ it("#4778 a relayed host capability does not end the remote lane before building; a failed relay still recovers locally", async () => {
741
+ const contract = { ...CONTRACT, validate: [...CONTRACT.validate, { id: "V2", command: "promtool test rules r.yml" }] };
742
+ const remote = { placement: { where: "remote", reason: "mode remote" }, hostCaps: { hostCapabilities: ["promtool"] } };
743
+ assert.equal((await run(world({ contract }), { ...remote, relayCaps: ["promtool"] })).terminalReason, "merged");
744
+ assert.equal((await run(world({ contract }), { ...remote, relayCaps: ["sops"] })).code, "env.capability_remote");
745
+ const blocked = world({ result: { status: "blocked", summary: "promtool relay unavailable", block: { kind: "human", why: "credentials" } } });
746
+ assert.equal((await run(blocked, { ...remote, relayCaps: ["promtool"] })).code, "env.capability_remote");
747
+ });
748
+
662
749
  it("needs-human terminals carry a specific outcome code, never unclassified", async () => {
663
750
  const code = async (w, extra) => {
664
751
  const dir = tmpDir("lean-code-");
@@ -862,6 +949,7 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
862
949
  const r = await run(w);
863
950
  assert.equal(r.code, "contract.invalid");
864
951
  assert.match(builders(w)[1][4], /R1: quote is not verbatim from the issue/);
952
+ assert.equal(r.quoteMismatch, true, "#4789: quote-only problems are recoverable");
865
953
  assert.equal(builders(w).length, 2, "plan + one correction, no implement call");
866
954
  assert.match(nhBody(w), /still invalid after one correction/);
867
955
  const w2 = world({ contract: bad1, fixedContract: CONTRACT });
@@ -1660,7 +1748,7 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
1660
1748
  assert.equal(hotspotBuildRule(null), ""); assert.equal(hotspotBuildRule(facts([], { enabled: false })), "");
1661
1749
  const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
1662
1750
  // 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line). 2050: #4724 (deferred-row bucket rule).
1663
- assert.ok(approxTokens(plan) <= 2050, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1751
+ assert.ok(approxTokens(plan) <= 2150, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
1664
1752
  assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
1665
1753
  });
1666
1754
 
@@ -2096,7 +2184,7 @@ describe("comment templates", () => {
2096
2184
 
2097
2185
  it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
2098
2186
  const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
2099
- assert.ok(approxTokens(plan) <= 1950, `plan prompt is ${approxTokens(plan)} tokens`); // 1950: #4724 (deferred-row bucket rule)
2187
+ assert.ok(approxTokens(plan) <= 2050, `plan prompt is ${approxTokens(plan)} tokens`); // 2050: #4769 (activation-row rule); 2000: #4730 (owner-step remainder); 1950: #4724 (deferred-row bucket rule)
2100
2188
  assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
2101
2189
  for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
2102
2190
  "investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
@@ -2560,7 +2648,7 @@ describe("orchestrate", () => {
2560
2648
  gh: async (a) => {
2561
2649
  if (a[0] === "repo" && a[1] === "view") return "main\n";
2562
2650
  if (a.includes("milestone")) return JSON.stringify({ milestone: milestones[a[2]] ? { title: milestones[a[2]] } : null });
2563
- return JSON.stringify({ body: a[2] === "1" ? "**Depends on**: #2" : "" });
2651
+ return JSON.stringify({ body: a[2] === "1" ? "**Depends on**: #2" : "", authorAssociation: "OWNER" });
2564
2652
  },
2565
2653
  git: async (a) => (a[0] === "ls-remote" && ["refs/heads/staging", "refs/heads/milestone/m-one"].includes(a.at(-1)) ? "abc\tref\n" : ""),
2566
2654
  };
@@ -2749,7 +2837,7 @@ describe("orchestrate maintenance budget (#4197)", () => {
2749
2837
  // No maintenance scan without the budget; labels (host priority, #4191) ride on the body call, never a call of their own.
2750
2838
  // #4399: the batch-start acceptance-probe sweep (`forge-probe` search) is not a maintenance scan.
2751
2839
  assert.ok(calls.some((c) => c.startsWith("issue list") && c.includes("forge-probe")), calls.join("\n"));
2752
- assert.ok(!calls.some((c) => (c.startsWith("issue list") && !c.includes("forge-probe")) || c.endsWith("--json labels")), calls.join("\n"));
2840
+ assert.ok(!calls.some((c) => (c.startsWith("issue list") && !c.includes("forge-probe") && !c.includes("--label blocked")) || c.endsWith("--json labels")), calls.join("\n"));
2753
2841
  ran.length = 0; calls.length = 0;
2754
2842
  const res = await orchestrateFromCli(["1", "--repo", "o/r", "--no-prefilter"], { ...mk(), maintenance: { share: 0.5, whenIdle: true, labels: ["maintenance"] } });
2755
2843
  assert.deepEqual(ran, [1, 9]);
@@ -4173,6 +4261,24 @@ describe("large diffs never reach argv (#4129)", () => {
4173
4261
  assert.equal(seen[1].args[1], "go"); assert.equal(seen[1].input, undefined);
4174
4262
  });
4175
4263
 
4264
+ it("#4763 promptGoesViaStdin is platform-aware and counts the other argv", async () => {
4265
+ const { promptGoesViaStdin, runCliBackend } = await import("../runner.mjs");
4266
+ assert.equal(promptGoesViaStdin("x".repeat(40 * 1024), [], "win32"), true);
4267
+ assert.equal(promptGoesViaStdin("x".repeat(10 * 1024), ["--print"], "win32"), false);
4268
+ assert.equal(promptGoesViaStdin("x".repeat(16 * 1024 - 10), ["--a".repeat(10)], "win32"), true);
4269
+ assert.equal(promptGoesViaStdin("x".repeat(40 * 1024), [], "linux"), false);
4270
+ assert.equal(promptGoesViaStdin("x".repeat(65 * 1024), [], "linux"), true);
4271
+ const prev = Object.getOwnPropertyDescriptor(process, "platform");
4272
+ Object.defineProperty(process, "platform", { value: "win32" });
4273
+ try {
4274
+ const seen = [];
4275
+ runCliBackend({ spec: { name: "t" }, userMessage: "y".repeat(40 * 1024), systemPrompt: "sys", args: ["1"], cwd: tmpDir("core-4763-"), logger: { log() {} },
4276
+ spawnFn: (_b, args, o2) => { seen.push({ args, input: o2.input }); return { status: 0, stdout: "{}", stderr: "" }; } });
4277
+ assert.equal(seen[0].input.length, 40 * 1024);
4278
+ assert.ok(!seen[0].args.some((x) => x.length > 1000));
4279
+ } finally { Object.defineProperty(process, "platform", prev); }
4280
+ });
4281
+
4176
4282
  it("runCliBackendAsync pipes a 300 KiB prompt to a real child (no E2BIG)", async () => {
4177
4283
  const { writeFileSync, chmodSync } = await import("node:fs");
4178
4284
  const { runCliBackendAsync } = await import("../runner.mjs");
@@ -5619,6 +5725,22 @@ describe("enumerating tests + sequence numbers at hand-off (#4322)", () => {
5619
5725
  assert.match(traj(w).at(-1), /renumbered deterministically \(no builder\): db\/0007_usage\.sql → db\/0008_usage\.sql/);
5620
5726
  });
5621
5727
 
5728
+ it("#4732: a collision plus an unrelated text conflict is base-synced (builder resolves only that file) and then renumbered by the engine; open-lane numbers reach the renumber", async () => {
5729
+ const NON = "non-sequence conflict: .env.example";
5730
+ const w = dworld({ seq: [COLL, []], mergeResults: ["conflict"], conflictFiles: [".env.example"], det: [{ unsupported: NON, nonSequence: [".env.example"] }, { ok: true, moves: MOVES, commit: "d".repeat(40) }], resolve: { remaining: [".env.example"] } });
5731
+ w.io.sequenceOpenNumbers = async (x) => { w.openArgs = x; return [9]; };
5732
+ const r = await run(w);
5733
+ assert.equal(r.terminalReason, "merged");
5734
+ assert.equal(w.renumbers.length, 2, "renumber, sync, renumber again");
5735
+ assert.ok(w.renumbers[1].laneRef, "second renumber reads the pre-merge lane head");
5736
+ assert.deepEqual(await w.renumbers[0].openNumbers({ dir: "db" }), [9]);
5737
+ assert.equal(w.openArgs.dir, "db");
5738
+ assert.equal(builders(w).length, 3, "plan + implement + the conflict resolution; no builder renumber");
5739
+ assert.match(builders(w)[2][4], /conflicts in: \.env\.example[\s\S]*do not renumber/);
5740
+ assert.deepEqual(w.resolveArgs.files, [".env.example"]);
5741
+ assert.equal(w.resolveArgs.partial, true);
5742
+ });
5743
+
5622
5744
  it("#4526: the renumber gets a reserve hook bound to (repo, lane, issue), and the run's reservations are dropped at finish", async () => {
5623
5745
  const w = dworld({ seq: [COLL, []] }), reserved = [], unreserved = [];
5624
5746
  w.io.sequenceLease.reserve = async (x) => { reserved.push(x); return { numbers: [9] }; };
@@ -6510,7 +6632,7 @@ describe("#4395 lane profile", () => {
6510
6632
  assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
6511
6633
  assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
6512
6634
  }
6513
- assert.ok(approxTokens(builderImplementPrompt()) <= 1650 && approxTokens(builderPlanPrompt(7)) <= 1950);
6635
+ assert.ok(approxTokens(builderImplementPrompt()) <= 1650 && approxTokens(builderPlanPrompt(7)) <= 2050);
6514
6636
  });
6515
6637
 
6516
6638
  it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {
@@ -0,0 +1,177 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * needs-human premises and the owner-ask registry (#4725).
5
+ *
6
+ * A blocker that names an external fact records it as a machine-checkable predicate, `Premise: <kind>:<args>`:
7
+ * issue-closed:<N> issue #N is closed
8
+ * date-after:<ISO date> now is at or past the date
9
+ * pkg-version:<name>@<ver> the registry's latest version of <name> is >= <ver> (`io.pkgVersion(name)`)
10
+ * It is re-evaluated before the label is applied (`needsHuman`, lean.mjs) and on the daemon timer (`sweepPremises`): a predicate
11
+ * that holds clears needs-human so the next scheduler pass picks the issue up. An unknown answer (unreadable, no resolver) never clears.
12
+ *
13
+ * Every owner ask is recorded once on GitHub as a `FORGE:ASK key=<hash>` annotation ({ask, unblocks:[issues]}); a new needs-human
14
+ * with the same normalized ask links to the existing entry. GitHub is the register: nothing here is a local cache. Pure apart from `io`.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { execFile } from "node:child_process";
18
+ import { isTrustedIssueComment } from "./prefilter.mjs";
19
+
20
+ export const PREMISE_MARKER = "FORGE:PREMISE";
21
+ export const ASK_MARKER = "FORGE:ASK";
22
+ const PREMISE_RE = /^\s*Premise:\s*(issue-closed|date-after|pkg-version):(\S+)\s*$/im;
23
+
24
+ /** The first `Premise:` line of a text as `{kind, arg}`, or null. */
25
+ export function parsePredicate(text) {
26
+ const m = PREMISE_RE.exec(String(text ?? ""));
27
+ if (!m) return null;
28
+ const kind = m[1].toLowerCase(), arg = m[2];
29
+ if (kind === "issue-closed") return /^#?\d+$/.test(arg) ? { kind, arg: String(Number(arg.replace("#", ""))) } : null;
30
+ if (kind === "date-after") return Number.isFinite(Date.parse(arg)) ? { kind, arg } : null;
31
+ return /^[^@\s]+@\d+(\.\d+)*[\w.+-]*$/.test(arg) ? { kind, arg } : null;
32
+ }
33
+
34
+ export const formatPredicate = (p) => (p ? `Premise: ${p.kind}:${p.arg}` : "");
35
+
36
+ /** Numeric dotted-version compare (pre-release tails are ignored): -1 | 0 | 1. */
37
+ export function comparePremiseVersions(a, b) {
38
+ const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0), pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
39
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
40
+ return 0;
41
+ }
42
+
43
+ /** Production `io.pkgVersion`: the npm registry's latest version of `name`, or null when unreachable. */
44
+ export function registryPkgVersion(name) {
45
+ return new Promise((resolve) => {
46
+ if (!/^(@[\w.-]+\/)?[\w.-]+$/.test(String(name))) return resolve(null);
47
+ execFile("npm", ["view", String(name), "version"], { timeout: 15000 }, (err, out) => resolve(err ? null : String(out).trim() || null));
48
+ });
49
+ }
50
+
51
+ /** true | false | null (unknown). Never throws. */
52
+ export async function evalPredicate(p, { repo, io, now = Date.now() } = {}) {
53
+ try {
54
+ if (!p) return null;
55
+ if (p.kind === "date-after") return now >= Date.parse(p.arg);
56
+ if (p.kind === "issue-closed") {
57
+ const v = JSON.parse(String(await io.gh(["issue", "view", p.arg, "-R", repo, "--json", "state"])));
58
+ const s = String(v?.state ?? "").toUpperCase();
59
+ return s === "CLOSED" ? true : s === "OPEN" ? false : null;
60
+ }
61
+ if (p.kind === "pkg-version") {
62
+ if (typeof io?.pkgVersion !== "function") return null;
63
+ const at = p.arg.lastIndexOf("@");
64
+ const cur = await io.pkgVersion(p.arg.slice(0, at));
65
+ return typeof cur === "string" && cur ? comparePremiseVersions(cur, p.arg.slice(at + 1)) >= 0 : null;
66
+ }
67
+ } catch { /* unknown */ }
68
+ return null;
69
+ }
70
+
71
+ /** The dependencies of `deps` whose issue is closed (`stateOf(n)` → "CLOSED"|"OPEN"|other): satisfied edges, never blockers. */
72
+ export async function closedEdges(deps, stateOf) {
73
+ const out = [];
74
+ for (const d of deps ?? []) {
75
+ let s = "";
76
+ try { s = String((await stateOf(d)) ?? "").toUpperCase(); } catch { s = ""; }
77
+ if (s === "CLOSED") out.push(d);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export const closedEdgeNote = (closed) => (closed?.length ? `Note: ${closed.map((n) => `#${n}`).join(", ")} ${closed.length > 1 ? "are" : "is"} closed, so ${closed.length > 1 ? "those" : "that"} \`Depends on\` edge${closed.length > 1 ? "s are" : " is"} satisfied and not a blocker.` : "");
83
+
84
+ /** The ask text lower-cased with issue refs, punctuation and spacing removed: the dedup key's input. */
85
+ export function normalizeAsk(text) {
86
+ return String(text ?? "").toLowerCase().replace(/#\d+/g, " ").replace(/[^a-z0-9]+/g, " ").trim();
87
+ }
88
+ export const askKey = (text) => createHash("sha256").update(normalizeAsk(text)).digest("hex").slice(0, 12);
89
+
90
+ const askComment = (key, ask, unblocks) => `<!-- ${ASK_MARKER} key=${key} -->\n## Owner ask\n${JSON.stringify({ ask: String(ask).slice(0, 500), unblocks })}`;
91
+ const readAsk = (body) => { try { return JSON.parse(String(body).split("\n").slice(2).join("\n")); } catch { return null; } };
92
+
93
+ /**
94
+ * Records `ask` for `issue` once. Returns `{key, existing}`: `existing` is the issue holding the entry (the new issue is added to its
95
+ * `unblocks`, nothing new is created) or null when this issue now holds it. Fail-open: an unreadable registry means a fresh entry.
96
+ */
97
+ export async function registerAsk({ repo, io, issue, ask }) {
98
+ const key = askKey(ask);
99
+ if (!normalizeAsk(ask)) return { key, existing: null };
100
+ try {
101
+ const list = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", "needs-human", "--limit", "100", "--search", `"${ASK_MARKER} key=${key}" in:comments`, "--json", "number,comments"])));
102
+ for (const i of Array.isArray(list) ? list : []) {
103
+ if (i?.number === issue) continue;
104
+ const c = (i.comments ?? []).find((x) => String(x?.body ?? "").includes(`${ASK_MARKER} key=${key}`) && isTrustedIssueComment(x)); // only trusted authors may register an ask
105
+ if (!c) continue;
106
+ const cur = readAsk(c.body) ?? { ask, unblocks: [i.number] };
107
+ const unblocks = [...new Set([...(cur.unblocks ?? []), i.number, issue])].sort((a, b) => a - b);
108
+ await io.gh(["issue", "comment", String(i.number), "-R", repo, "--body", askComment(key, cur.ask ?? ask, unblocks)]);
109
+ return { key, existing: i.number, unblocks };
110
+ }
111
+ } catch { /* fail-open */ }
112
+ return { key, existing: null, entry: askComment(key, ask, [issue]) };
113
+ }
114
+
115
+ /**
116
+ * The needs-human issues whose recorded premise now holds lose the label (and `needs-human:external`) with a comment; the next
117
+ * scheduler pass picks them up. Rows: cleared | still-blocked | unknown | error. Never throws.
118
+ */
119
+ export async function sweepPremises({ repo, io, now = Date.now(), dryRun = false, onResult = null } = {}) {
120
+ const rows = [];
121
+ let list = [];
122
+ try { list = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", "needs-human", "--limit", "100", "--json", "number,comments"]))); } catch { return rows; }
123
+ for (const i of (Array.isArray(list) ? list : []).sort((a, b) => a.number - b.number)) {
124
+ // Only the current block counts: the newest trusted FORGE:NEEDS_HUMAN comment, and only when it carries the premise. A
125
+ // later block without one (credentials, spend, ci.red) supersedes an older premise; an untrusted premise never counts.
126
+ const c = [...(i.comments ?? [])].reverse().find((x) => String(x?.body ?? "").includes("FORGE:NEEDS_HUMAN") && isTrustedIssueComment(x));
127
+ if (!c || !String(c.body).includes(`${PREMISE_MARKER} `)) continue;
128
+ let row;
129
+ try {
130
+ const p = parsePredicate(String(c.body).replace(/<!--\s*FORGE:PREMISE\s+(.*?)\s*-->/s, "Premise: $1"));
131
+ const held = await evalPredicate(p, { repo, io, now });
132
+ row = { issue: i.number, outcome: held === true ? "cleared" : held === false ? "still-blocked" : "unknown", predicate: formatPredicate(p) };
133
+ if (held === true && !dryRun) {
134
+ await io.gh(["issue", "edit", String(i.number), "-R", repo, "--remove-label", "needs-human", "--remove-label", "needs-human:external"]);
135
+ await io.gh(["issue", "comment", String(i.number), "-R", repo, "--body", `## Premise no longer holds\n\`${formatPredicate(p)}\` is now true: \`needs-human\` was cleared and the issue is back in the queue.`]);
136
+ }
137
+ } catch (e) { row = { issue: i.number, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) }; }
138
+ rows.push(row);
139
+ try { if (!dryRun) await onResult?.(row); } catch { /* an event sink never fails the sweep */ }
140
+ }
141
+ return rows;
142
+ }
143
+
144
+ /** The marker line a needs-human comment carries so the sweep can re-read the predicate. */
145
+ export const premiseAnnotation = (p) => (p ? `<!-- ${PREMISE_MARKER} ${p.kind}:${p.arg} -->` : "");
146
+
147
+ const strip = (t) => String(t ?? "").replace(/<!--[\s\S]*?-->/g, " ").replace(/^\s*\**(parent|depends on)\**\s*:.*$/gim, " ");
148
+ /** #4730: true when an issue's content (markers and Parent/Depends lines ignored) is nothing beyond `ask`: strict normalized equality of the body, or of the title when the body is empty. */
149
+ export function onlyAsk(title, body, ask) {
150
+ const a = normalizeAsk(ask), b = normalizeAsk(strip(body));
151
+ return Boolean(a) && (b ? b === a : normalizeAsk(title) === a);
152
+ }
153
+
154
+ export const REMAINDER_MARKER = "FORGE:REMAINDER";
155
+ /**
156
+ * #4730: files the agent-doable remainder of an owner-blocked lane as its own issues (no needs-human, `Parent: #N`). Idempotent: an
157
+ * item whose `FORGE:REMAINDER of=#N key=<hash>` marker already exists on an issue is reused. Returns the issue numbers, in item order.
158
+ */
159
+ export async function fileRemainder({ repo, io, issue, items, milestone = null }) {
160
+ const out = [];
161
+ for (const it of items ?? []) {
162
+ const key = askKey(it.title), mark = `<!-- ${REMAINDER_MARKER} of=#${issue} key=${key} -->`;
163
+ let n = null;
164
+ try {
165
+ const list = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "all", "--limit", "20", "--search", `"${REMAINDER_MARKER} of=#${issue} key=${key}" in:body`, "--json", "number,body"])));
166
+ n = (Array.isArray(list) ? list : []).find((i) => String(i?.body ?? "").includes(mark))?.number ?? null;
167
+ } catch { /* fail-open: file it */ }
168
+ if (n == null) {
169
+ const body = `${it.body.trim()}\n\n**Parent**: #${issue}\n${mark}`;
170
+ const res = String(await io.create(["issue", "create", "-R", repo, "--title", it.title, "--body", body, ...(milestone ? ["--milestone", milestone] : [])]));
171
+ n = Number(/\/issues\/(\d+)/.exec(res)?.[1]);
172
+ if (!Number.isSafeInteger(n)) throw new Error(`cannot parse the remainder issue number from: ${res.slice(0, 200)}`);
173
+ }
174
+ out.push(n);
175
+ }
176
+ return out;
177
+ }
@@ -0,0 +1,136 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4725: needs-human premises, closed edges, owner-ask registry, and the batch parking path.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { parsePredicate, evalPredicate, closedEdges, closedEdgeNote, normalizeAsk, askKey, registerAsk, onlyAsk, fileRemainder, sweepPremises, comparePremiseVersions, premiseAnnotation } from "./needs-human-premise.mjs";
7
+ import { orchestrate } from "./orchestrate.mjs";
8
+
9
+ const APP = { author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR" }; // the ForgeDock App, trusted
10
+ const fakeGh = (handlers) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); for (const [re, f] of handlers) if (re.test(a.join(" "))) return f(a); return ""; } }; };
11
+
12
+ describe("predicates (#4725)", () => {
13
+ it("parses and rejects", () => {
14
+ assert.deepEqual(parsePredicate("blocked\nPremise: issue-closed:#12"), { kind: "issue-closed", arg: "12" });
15
+ assert.deepEqual(parsePredicate("Premise: pkg-version:curl-cffi@0.15.0"), { kind: "pkg-version", arg: "curl-cffi@0.15.0" });
16
+ assert.equal(parsePredicate("Premise: date-after:nope"), null);
17
+ assert.equal(parsePredicate("no premise here"), null);
18
+ });
19
+ it("evaluates date, issue state and package version; unknown is null", async () => {
20
+ assert.equal(await evalPredicate({ kind: "date-after", arg: "2026-01-01" }, { now: Date.parse("2026-02-01") }), true);
21
+ const { gh } = fakeGh([[/issue view 7/, () => '{"state":"CLOSED"}'], [/issue view 8/, () => '{"state":"OPEN"}']]);
22
+ assert.equal(await evalPredicate({ kind: "issue-closed", arg: "7" }, { repo: "o/r", io: { gh } }), true);
23
+ assert.equal(await evalPredicate({ kind: "issue-closed", arg: "8" }, { repo: "o/r", io: { gh } }), false);
24
+ assert.equal(await evalPredicate({ kind: "pkg-version", arg: "x@1.2.0" }, { io: { pkgVersion: async () => "1.10.0" } }), true);
25
+ assert.equal(await evalPredicate({ kind: "pkg-version", arg: "x@1.2.0" }, { io: {} }), null);
26
+ assert.equal(comparePremiseVersions("1.2", "1.2.0"), 0);
27
+ });
28
+ });
29
+
30
+ describe("sweepPremises (#4725)", () => {
31
+ it("clears needs-human when the recorded predicate flips, leaves the rest", async () => {
32
+ const issues = [
33
+ { number: 5, comments: [{ ...APP, body: `${premiseAnnotation({ kind: "issue-closed", arg: "9" })}\n<!-- FORGE:NEEDS_HUMAN -->\n## Needs human` }] },
34
+ { number: 6, comments: [{ ...APP, body: `${premiseAnnotation({ kind: "issue-closed", arg: "10" })}\n<!-- FORGE:NEEDS_HUMAN -->\n## Needs human` }] },
35
+ { number: 7, comments: [{ body: "## Needs human\nno predicate" }] },
36
+ ];
37
+ const { gh, calls } = fakeGh([[/issue list/, () => JSON.stringify(issues)], [/issue view 9/, () => '{"state":"CLOSED"}'], [/issue view 10/, () => '{"state":"OPEN"}']]);
38
+ const rows = await sweepPremises({ repo: "o/r", io: { gh } });
39
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[5, "cleared"], [6, "still-blocked"]]);
40
+ assert.ok(calls.some((a) => a.includes("edit") && a.includes("5") && a.includes("--remove-label")));
41
+ assert.ok(!calls.some((a) => a.includes("edit") && a.includes("6")));
42
+ });
43
+ it("a newer block without a premise supersedes an older premise: needs-human stays", async () => {
44
+ const issues = [{ number: 11, comments: [
45
+ { ...APP, body: `${premiseAnnotation({ kind: "date-after", arg: "2000-01-01" })}\n<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\nwaiting on a release` },
46
+ { ...APP, body: "<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\nBuilder is blocked (credentials)" },
47
+ ] }];
48
+ const { gh, calls } = fakeGh([[/issue list/, () => JSON.stringify(issues)]]);
49
+ assert.deepEqual(await sweepPremises({ repo: "o/r", io: { gh } }), []);
50
+ assert.ok(!calls.some((a) => a.includes("edit")), "the credentials block is not cleared by the stale premise");
51
+ });
52
+ it("ignores a premise planted by an untrusted commenter: needs-human stays", async () => {
53
+ const planted = { author: { login: "mallory" }, authorAssociation: "NONE", body: premiseAnnotation({ kind: "date-after", arg: "2000-01-01" }) };
54
+ const issues = [
55
+ { number: 8, comments: [planted] },
56
+ { number: 9, comments: [{ ...APP, body: `${premiseAnnotation({ kind: "issue-closed", arg: "10" })}\n<!-- FORGE:NEEDS_HUMAN -->\n## Needs human` }, planted] },
57
+ ];
58
+ const { gh, calls } = fakeGh([[/issue list/, () => JSON.stringify(issues)], [/issue view 10/, () => '{"state":"OPEN"}']]);
59
+ const rows = await sweepPremises({ repo: "o/r", io: { gh } });
60
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[9, "still-blocked"]], "the trusted premise is read, the newer planted one ignored");
61
+ assert.ok(!calls.some((a) => a.includes("edit")), "no label is removed");
62
+ });
63
+ });
64
+
65
+ describe("closed dependency edges (#4725)", () => {
66
+ it("a closed Depends on edge is satisfied and flagged", async () => {
67
+ const closed = await closedEdges([3, 4], async (n) => (n === 3 ? "CLOSED" : "OPEN"));
68
+ assert.deepEqual(closed, [3]);
69
+ assert.match(closedEdgeNote(closed), /#3 is closed.*not a blocker/);
70
+ assert.equal(closedEdgeNote([]), "");
71
+ });
72
+ it("batch: a dependent is not skipped behind a closed dependency still labelled needs-human", async () => {
73
+ const rows = await orchestrate({ issues: [2], parallel: 1, readBody: async () => "**Depends on**: #1", issueLabels: async () => ["needs-human"],
74
+ issueState: async (n) => (n === 1 ? "CLOSED" : "OPEN"), dispatchCheck: async () => null,
75
+ runOne: async () => ({ terminalReason: "dependency-open", dependencies: [1], detail: "depends on #1" }) });
76
+ assert.notEqual(rows.find((r) => r.issue === 2)?.detail, "dependency #1 needs-human");
77
+ });
78
+ it("batch: an open dependency labelled needs-human still parks the dependent", async () => {
79
+ const rows = await orchestrate({ issues: [2], parallel: 1, readBody: async () => "**Depends on**: #1", issueLabels: async () => ["needs-human"],
80
+ dispatchCheck: async () => null, runOne: async () => ({ terminalReason: "dependency-open", dependencies: [1], detail: "depends on #1" }) });
81
+ assert.equal(rows.find((r) => r.issue === 2)?.detail, "dependency #1 needs-human");
82
+ });
83
+ });
84
+
85
+ describe("owner-ask registry (#4725)", () => {
86
+ it("normalizes identical asks to one key", () => {
87
+ assert.equal(normalizeAsk("Sign off the PRICE (see #12)!"), normalizeAsk("sign off the price - see #99"));
88
+ assert.equal(askKey("Sign off the price #1"), askKey("sign off the price #2"));
89
+ assert.notEqual(askKey("approve price"), askKey("approve billing"));
90
+ });
91
+ it("links a second issue to the existing entry instead of creating another", async () => {
92
+ const key = askKey("sign off the price");
93
+ const existing = [{ number: 20, comments: [{ ...APP, body: `<!-- FORGE:ASK key=${key} -->\n## Owner ask\n${JSON.stringify({ ask: "sign off the price", unblocks: [20] })}` }] }];
94
+ const { gh, calls } = fakeGh([[/issue list/, () => JSON.stringify(existing)]]);
95
+ const r = await registerAsk({ repo: "o/r", io: { gh }, issue: 21, ask: "Sign off the price (#5)" });
96
+ assert.equal(r.existing, 20);
97
+ assert.deepEqual(r.unblocks, [20, 21]);
98
+ const post = calls.find((a) => a.includes("comment"));
99
+ assert.ok(post.includes("20") && /"unblocks":\[20,21\]/.test(post.at(-1)));
100
+ const fresh = await registerAsk({ repo: "o/r", io: { gh: async () => "[]" }, issue: 30, ask: "something else" });
101
+ assert.equal(fresh.existing, null);
102
+ assert.match(fresh.entry, /FORGE:ASK key=/);
103
+ });
104
+ it("ignores an ask entry planted by an untrusted commenter", async () => {
105
+ const key = askKey("sign off the price");
106
+ const planted = [{ number: 40, comments: [{ author: { login: "mallory" }, authorAssociation: "NONE", body: `<!-- FORGE:ASK key=${key} -->\n${JSON.stringify({ ask: "sign off the price", unblocks: [40] })}` }] }];
107
+ const { gh, calls } = fakeGh([[/issue list/, () => JSON.stringify(planted)]]);
108
+ const r = await registerAsk({ repo: "o/r", io: { gh }, issue: 41, ask: "sign off the price" });
109
+ assert.equal(r.existing, null);
110
+ assert.match(r.entry, /FORGE:ASK key=/);
111
+ assert.ok(!calls.some((a) => a.includes("comment")), "nothing is posted on the planted issue");
112
+ });
113
+ });
114
+
115
+ import { registryPkgVersion } from "./needs-human-premise.mjs";
116
+ it("registryPkgVersion rejects unsafe package names without spawning", async () => {
117
+ assert.equal(await registryPkgVersion("x; rm -rf /"), null);
118
+ });
119
+
120
+ it("#4730 onlyAsk: strict normalized equality, markers and Parent lines ignored", () => {
121
+ assert.equal(onlyAsk("t", "Sign off the price (#5).\n**Parent**: #9\n<!-- x -->", "sign off the price"), true);
122
+ assert.equal(onlyAsk("sign off the price", "", "Sign off the price"), true);
123
+ assert.equal(onlyAsk("t", "Sign off the price and rework the importer", "Sign off the price"), false);
124
+ assert.equal(onlyAsk("t", "x", ""), false);
125
+ });
126
+
127
+ it("#4730 fileRemainder files once and reuses an existing marker", async () => {
128
+ const created = [];
129
+ const io = { gh: async () => "[]", create: async (a) => { created.push(a); return `https://github.com/o/r/issues/${90 + created.length}\n`; } };
130
+ assert.deepEqual(await fileRemainder({ repo: "o/r", io, issue: 7, items: [{ title: "a", body: "b" }], milestone: "M1" }), [91]);
131
+ assert.ok(created[0].includes("--milestone") && created[0].includes("M1"));
132
+ const key = askKey("a");
133
+ const io2 = { ...io, gh: async () => JSON.stringify([{ number: 5, body: `<!-- FORGE:REMAINDER of=#7 key=${key} -->` }]) };
134
+ assert.deepEqual(await fileRemainder({ repo: "o/r", io: io2, issue: 7, items: [{ title: "a", body: "b" }] }), [5]);
135
+ assert.equal(created.length, 1);
136
+ });
@@ -0,0 +1,71 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4773: issue-level readiness: trusted `Blocked by` edges and the `blocked` label.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { orchestrate } from "./orchestrate.mjs";
7
+
8
+ const until = async (f, ms = 3000) => { const t = Date.now(); while (!f()) { if (Date.now() - t > ms) throw new Error("timeout"); await new Promise((r) => setTimeout(r, 5)); } };
9
+ const merged = { terminalReason: "merged" };
10
+
11
+ describe("#4773: Blocked by edges and the blocked label", () => {
12
+ it("an issue whose body says Blocked by #A is not dispatched before #A merges, whatever the batch order", async () => {
13
+ const started = [];
14
+ const rows = await orchestrate({ issues: [2, 1], parallel: 3, pollMs: 5, trusted: () => true, readBody: async (n) => (n === 2 ? "Blocked by #1" : ""),
15
+ runOne: async (n) => { started.push(n); await new Promise((r) => setTimeout(r, n === 1 ? 30 : 0)); return merged; } });
16
+ assert.deepEqual(started, [1, 2]);
17
+ assert.ok(rows.every((r) => r.outcome === "merged"));
18
+ });
19
+
20
+ it("adding #A later to the same batch orders it before the issue that is blocked by it", async () => {
21
+ const bodies = { 1: "", 2: "Blocked by #3", 3: "" };
22
+ let added = [];
23
+ const started = []; let open;
24
+ const g = new Promise((r) => { open = r; });
25
+ const done = orchestrate({ issues: [1, 2], parallel: 1, pollMs: 5, trusted: () => true, readBody: async (n) => bodies[n], getAdded: () => added,
26
+ runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; } });
27
+ await until(() => started.includes(1));
28
+ added = [3];
29
+ await new Promise((r) => setTimeout(r, 30));
30
+ open();
31
+ await done;
32
+ assert.deepEqual(started, [1, 3, 2]);
33
+ });
34
+
35
+ it("an untrusted author's Blocked by line makes no edge", async () => {
36
+ const started = [];
37
+ await orchestrate({ issues: [2, 1], parallel: 1, pollMs: 5, trusted: () => false, readBody: async (n) => (n === 2 ? "Blocked by #1" : ""),
38
+ runOne: async (n) => { started.push(n); return merged; } });
39
+ assert.deepEqual(started, [2, 1]);
40
+ });
41
+
42
+ it("a blocked-labelled issue is admitted but not dispatched; removing the label releases it on the next tick", async () => {
43
+ let blocked = [1];
44
+ const started = [], log = [];
45
+ const done = orchestrate({ issues: [1, 2], parallel: 3, pollMs: 5, readBody: async () => "", getBlocked: async () => blocked, onUpdate: (r) => log.push(r),
46
+ runOne: async (n) => { started.push(n); return merged; } });
47
+ await until(() => started.includes(2));
48
+ await new Promise((r) => setTimeout(r, 40));
49
+ assert.deepEqual(started, [2], "#1 waits while labelled");
50
+ blocked = [];
51
+ const rows = await done;
52
+ assert.deepEqual(started, [2, 1]);
53
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
54
+ assert.deepEqual(log.filter((r) => ["blocked", "unblocked"].includes(r.outcome)).map((r) => [r.outcome, r.detail]), [["blocked", "#1"], ["unblocked", "#1"]]);
55
+ });
56
+
57
+ it("a failed label read keeps the last set instead of releasing", async () => {
58
+ let fail = false, set = [1], reads = 0;
59
+ const started = [];
60
+ const done = orchestrate({ issues: [1], parallel: 1, pollMs: 5, readBody: async () => "", getBlocked: async () => { reads++; if (fail) throw new Error("gh down"); return set; },
61
+ runOne: async (n) => { started.push(n); return merged; } });
62
+ await until(() => reads > 0);
63
+ fail = true;
64
+ const seen = reads;
65
+ await until(() => reads > seen + 2);
66
+ assert.deepEqual(started, []);
67
+ fail = false; set = [];
68
+ await done;
69
+ assert.deepEqual(started, [1]);
70
+ });
71
+ });