forgedock 1.46.0 → 1.48.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 (69) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/issuewatch.mjs +134 -37
  20. package/bin/lean/issuewatch.test.mjs +123 -1
  21. package/bin/lean/lane.mjs +13 -0
  22. package/bin/lean/lane.test.mjs +28 -1
  23. package/bin/lean/lean.mjs +60 -12
  24. package/bin/lean/lean.test.mjs +80 -0
  25. package/bin/lean/limits.mjs +6 -2
  26. package/bin/lean/limits.test.mjs +86 -0
  27. package/bin/lean/milestonesync.mjs +2 -2
  28. package/bin/lean/milestonesync.test.mjs +22 -0
  29. package/bin/lean/orchestrate.mjs +79 -9
  30. package/bin/lean/probe.mjs +4 -2
  31. package/bin/lean/probe.test.mjs +4 -0
  32. package/bin/lean/prompts.mjs +18 -11
  33. package/bin/lean/rotator.mjs +96 -0
  34. package/bin/lean/rotator.test.mjs +143 -0
  35. package/bin/lean/routecheck.mjs +48 -0
  36. package/bin/lean/routecheck.test.mjs +69 -0
  37. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  38. package/bin/lean/sandbox-remote.mjs +4 -2
  39. package/bin/lean/verify.mjs +2 -1
  40. package/bin/review-staging.mjs +20 -3
  41. package/commands/deploy-info.md +4 -4
  42. package/commands/issue.md +6 -1
  43. package/commands/review-pr-agents/frontend.md +1 -0
  44. package/commands/review-pr-agents/infra.md +2 -0
  45. package/commands/review-pr-staging.md +2 -0
  46. package/commands/work-on/build/context.md +7 -1
  47. package/commands/work-on/investigate.md +15 -3
  48. package/docs/CONFIG.md +21 -2
  49. package/docs/ISSUE-WATCH.md +22 -1
  50. package/docs/OUTCOME-CODES.md +2 -1
  51. package/package.json +1 -1
  52. package/scripts/file-deferred-followups.sh +125 -28
  53. package/scripts/file-deferred-followups.test.sh +49 -0
  54. package/scripts/issue-create.sh +7 -0
  55. package/scripts/issue-create.test.sh +13 -0
  56. package/scripts/issue-dedup.sh +5 -4
  57. package/scripts/issue-dedup.test.sh +15 -0
  58. package/scripts/owner-route-parity.test.sh +32 -0
  59. package/scripts/plan-validate-live.mjs +18 -6
  60. package/scripts/prior-attempts.mjs +72 -0
  61. package/scripts/prior-attempts.test.mjs +52 -0
  62. package/scripts/review-finalize.sh +35 -4
  63. package/scripts/review-finalize.test.sh +10 -0
  64. package/scripts/review-prepare.sh +16 -4
  65. package/scripts/review-prepare.test.sh +8 -0
  66. package/scripts/review-roster.sh +3 -1
  67. package/scripts/review-roster.test.sh +30 -2
  68. package/scripts/review-staging-pre.sh +8 -6
  69. package/scripts/review-staging-pre.test.sh +11 -1
@@ -7,9 +7,14 @@
7
7
  * symbol from the issue, and repo-fact file pointers. Only GitHub and git are read; nothing here is ForgeDock-specific
8
8
  * beyond the FORGE:* records lanes already write. `gatherContextFacts` does the I/O; `compileContextPack` is pure, so the
9
9
  * same facts always give a byte-identical pack. The pack is a hint: it never enters the issue text contracts quote from.
10
+ * #5085: `attempts` also carries subject history: closed issues and merged PRs found by the issue's subject terms (codes,
11
+ * symbols, title words; `subjectTerms`) through GitHub search and a repo-wide `git log --grep`, whatever files they touched.
12
+ * The pack also reports whether the Forge Ledger recall index exists, so a missing index is never skipped silently.
10
13
  */
11
14
  import { createHash } from "node:crypto";
12
15
  import { join } from "node:path";
16
+ import { homedir } from "node:os";
17
+ import { fileURLToPath } from "node:url";
13
18
  import { parseDependsOn } from "../engine/phases.mjs";
14
19
  import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
15
20
  import { runLogKey } from "../engine/runlog.mjs";
@@ -33,6 +38,12 @@ export const MAX_LINKS = 20;
33
38
  export const MAX_LEDGER_PRS = 8;
34
39
  /** #4401: FORGE:FACTS records (earlier lanes) read into one pack: this issue's own attempts first, then the file-path ledger. */
35
40
  export const MAX_FACT_LANES = 6;
41
+ /** #5085: subject-history bounds per pack: GitHub searches run, rows kept. */
42
+ export const MAX_SUBJECT_QUERIES = 3;
43
+ export const MAX_SUBJECT_ITEMS = 8;
44
+ /** #5085: where the Forge Ledger recall CLI ships (the ForgeDock install) and its default index file (bin/recall.mjs). */
45
+ const RECALL_INSTALL = fileURLToPath(new URL("../recall.mjs", import.meta.url));
46
+ const RECALL_INDEX = join(homedir(), ".forge", "index", "knowledge.jsonl");
36
47
  export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
37
48
  const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
38
49
  const LINE_MAX = 200;
@@ -106,6 +117,29 @@ export function issueSymbols(body) {
106
117
  const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
107
118
  const namesSymbol = (text, symbols) => symbols.some((s) => new RegExp(`(?:^|[^\\w$])${reEscape(s)}(?:$|[^\\w$])`).test(String(text)));
108
119
 
120
+ const STOPWORDS = new Set(("about after again also always another because before being between both cannot could does doing during each every from have having into issue issues just make makes more most must never only other over same should some such than that their them then there these they this those through under until very what when where which while will with within without would your " +
121
+ "fix fixes fixed bug bugs feat chore docs test tests file files when wrong error errors fail fails failed failing add adds remove update use uses").split(" "));
122
+ const FILE_EXT = /\.(?:m?[jt]sx?|cjs|md|sh|json|ya?ml|py|go|rs|rb|css|html|txt|toml|lock|sql)$/i;
123
+ /** #5085: subject terms split by kind: codes and symbols (exact, quoted searches) and distinctive title words. */
124
+ function subjectParts({ title = "", body = "" } = {}) {
125
+ const text = `${stripComments(title)}\n${stripComments(body)}`;
126
+ const codes = [];
127
+ const add = (t) => { if (t && !codes.some((c) => c.toLowerCase() === t.toLowerCase())) codes.push(t); };
128
+ for (const m of text.matchAll(/`([a-z][a-z0-9_.-]{2,63})`/g)) if (/[._-]/.test(m[1]) && !FILE_EXT.test(m[1]) && /[a-z0-9]$/.test(m[1])) add(m[1]);
129
+ for (const m of text.matchAll(/(?<![\w/#:])([A-Z][A-Z0-9]*_[A-Z0-9_]*[A-Z0-9])(?![\w:])/g)) if (!/^FORGE_/.test(m[1])) add(m[1]);
130
+ for (const m of text.matchAll(/\bHTTP\s*([1-5]\d\d)\b|(?<![\w#])([1-5]xx)\b/gi)) add(m[1] ?? m[2].toLowerCase());
131
+ for (const s of issueSymbols(body)) if (!codes.some((c) => c.toLowerCase().endsWith(`.${s.toLowerCase()}`))) add(s);
132
+ const head = stripComments(title).replace(/^\s*\w+(?:\([^)]*\))?!?:\s*/, "");
133
+ const words = [];
134
+ for (const w of head.toLowerCase().match(/[a-z][a-z0-9-]{3,}/g) ?? []) if (!STOPWORDS.has(w) && !words.includes(w) && !codes.some((c) => c.toLowerCase() === w)) words.push(w);
135
+ return { codes: codes.slice(0, 3), words: words.slice(0, 3) };
136
+ }
137
+ /** #5085: the issue's subject terms, codes and symbols first, then title words; deterministic, deduped, at most 6. */
138
+ export function subjectTerms({ title = "", body = "" } = {}) {
139
+ const { codes, words } = subjectParts({ title, body });
140
+ return [...codes, ...words];
141
+ }
142
+
109
143
  /** Rerun (an earlier run left a record or salvage), decomposed child (`**Parent**`), or first attempt. */
110
144
  export function laneType({ resumed = false, attempts = 0, body = "" } = {}) {
111
145
  if (resumed || attempts > 0) return "rerun";
@@ -186,13 +220,74 @@ async function readComments(gh, repo, n) {
186
220
  return parseLines(await gh(["api", "--paginate", `repos/${repo}/issues/${n}/comments`, "--jq", ".[] | {id, body, author_association, user: {type: .user.type, login: .user.login}} | @json"]));
187
221
  }
188
222
 
223
+ /**
224
+ * #5085: closed issues and merged PRs on the same subject, found by term rather than by issue number or file: up to
225
+ * MAX_SUBJECT_QUERIES GitHub searches (each code/symbol quoted, then the title words together) and one `git log --grep`
226
+ * over the whole history of `base`. `exclude` (self, linked issues) is dropped; rows are ranked by the number of
227
+ * matched terms, then newest first, and capped. A failed query lands in `errors`; the rest still count.
228
+ * @returns {Promise<{terms:string[], rows:{number:number, kind:string, status:string, title:string, matched:string[]}[], errors:string[]}>}
229
+ */
230
+ export async function subjectHistory({ io, repo, issue = null, title = "", body = "", base = null, exclude = [], max = MAX_SUBJECT_ITEMS }) {
231
+ const { codes, words } = subjectParts({ title, body });
232
+ const terms = [...codes, ...words], errors = [], hits = new Map();
233
+ const skip = new Set([Number(issue), ...exclude.map(Number)]);
234
+ const hit = (n, info, matched) => {
235
+ if (!Number.isSafeInteger(n) || n <= 0 || skip.has(n)) return;
236
+ const row = hits.get(n) ?? { number: n, kind: info.kind, status: info.status, title: info.title, matched: [] };
237
+ if (info.src === "gh") Object.assign(row, { kind: info.kind, status: info.status, title: info.title || row.title });
238
+ for (const t of matched) if (!row.matched.includes(t)) row.matched.push(t);
239
+ hits.set(n, row);
240
+ };
241
+ if (!terms.length) return { terms, rows: [], errors };
242
+ const queries = [...codes.slice(0, words.length ? MAX_SUBJECT_QUERIES - 1 : MAX_SUBJECT_QUERIES).map((c) => [c]), ...(words.length ? [words] : [])].slice(0, MAX_SUBJECT_QUERIES);
243
+ for (const q of queries) {
244
+ const text = q.length === 1 && codes.includes(q[0]) ? `"${q[0]}"` : q.join(" ");
245
+ try {
246
+ const out = await io.gh(["api", "-X", "GET", "search/issues", "-f", `q=repo:${repo} is:closed ${text}`, "-f", "per_page=10", "--jq",
247
+ ".items[] | {number, title, state_reason, pr: (.pull_request != null), merged: (.pull_request.merged_at != null)} | @json"]);
248
+ for (const it of parseLines(out)) {
249
+ const status = it.pr ? (it.merged ? "merged" : "closed unmerged") : (it.state_reason ? String(it.state_reason) : "closed");
250
+ hit(Number(it.number), { src: "gh", kind: it.pr ? "PR" : "issue", status: codeOf(status.replace(" ", "-")) ? status : "closed", title: oneLine(it.title, 120) }, q);
251
+ }
252
+ } catch (e) { errors.push(`attempts: subject search ${oneLine(e?.message ?? e, 80)}`); }
253
+ }
254
+ if (base && io.git) {
255
+ try {
256
+ const greps = codes.length ? ["-E", `--grep=${codes.map(reEscape).join("|")}`] : ["--all-match", ...words.map((w) => `--grep=${reEscape(w)}`)];
257
+ const out = String(await io.git(["log", "-i", ...greps, "-n", "20", "--format=%x1e%H%x1f%s%x1f%b", base]));
258
+ for (const rec of out.split("\x1e").filter((x) => x.trim())) {
259
+ const [, subject = "", msg = ""] = rec.split("\x1f");
260
+ const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject.trim())?.[1]) || null;
261
+ const hay = `${subject}\n${msg}`.toLowerCase();
262
+ const matched = (codes.length ? codes : words).filter((t) => hay.includes(t.toLowerCase()));
263
+ if (!pr || !matched.length || (!codes.length && matched.length < words.length)) continue;
264
+ const named = /^Merge pull request/.test(subject) ? msg.split("\n").map((l) => l.trim()).find(Boolean) ?? subject : subject;
265
+ hit(pr, { src: "git", kind: "PR", status: "merged", title: oneLine(named, 120) }, matched);
266
+ }
267
+ } catch (e) { errors.push(`attempts: subject git log ${oneLine(e?.message ?? e, 80)}`); }
268
+ }
269
+ const rows = [...hits.values()].sort((a, b) => b.matched.length - a.matched.length || b.number - a.number)
270
+ .slice(0, max);
271
+ return { terms, rows, errors };
272
+ }
273
+
274
+ /** #5085: is the Forge Ledger recall CLI (repo or ForgeDock install) and its index there? null when it cannot be probed. */
275
+ async function recallStatus(io, dirs, index) {
276
+ if (typeof io?.exists !== "function") return null;
277
+ let cli = false;
278
+ for (const p of [...dirs.filter(Boolean).map((d) => `${d}/bin/recall.mjs`), RECALL_INSTALL]) if (await io.exists(p)) { cli = true; break; }
279
+ if (!cli) return { present: false, reason: "bin/recall.mjs missing" };
280
+ if (!(await io.exists(index))) return { present: false, reason: "index ~/.forge/index missing" };
281
+ return { present: true, reason: "" };
282
+ }
283
+
189
284
  /**
190
285
  * Collect the facts for one issue's pack. `io`: {gh, git, exists}; git runs in `cwd` (the lane worktree), base is the
191
286
  * base ref; `checkout` (the main checkout, optional) is where repo-fact files absent from the worktree are looked up.
192
287
  * Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
193
288
  */
194
- export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [] }) {
195
- const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [] };
289
+ export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [], recallIndex = RECALL_INDEX }) {
290
+ const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [], recall: null };
196
291
  const body = String(iss?.body ?? "");
197
292
  const on = new Set(sources);
198
293
  const step = async (name, fn) => { if (!on.has(name)) return; try { await fn(); } catch (e) { facts.errors.push(`${name}: ${oneLine(e?.message ?? e, 120)}`); } };
@@ -243,7 +338,13 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
243
338
  }
244
339
  const refs = String(await git(["for-each-ref", "--format=%(refname) %(objectname:short)", `refs/forge/salvage/${issue}/`])).split("\n").map((l) => l.trim()).filter(Boolean).sort();
245
340
  for (const l of refs) { const [ref, sha] = l.split(" "); facts.attempts.push({ kind: "salvage", ref, line: `salvaged uncommitted work at ${sha ?? "?"} (git diff HEAD ${ref} --stat)` }); }
341
+ // #5085: earlier issues and PRs on the same subject, deduped against the graph (or, with graph off, the body's own links)
342
+ const exclude = [...facts.links.map((l) => l.number), ...bodyLinks(body, repo, issue), ...parseDependsOn(stripComments(body), issue), ...(parentOf(body) ? [parentOf(body)] : [])];
343
+ const sh = await subjectHistory({ io: { gh: io.gh, git }, repo, issue, title: String(iss?.title ?? ""), body, base, exclude });
344
+ facts.errors.push(...sh.errors);
345
+ for (const r of sh.rows) facts.attempts.push({ kind: "subject", ref: `#${r.number}`, line: `[${r.kind}] ${r.status} "${r.title}"; matched ${r.matched.join(", ")}` });
246
346
  });
347
+ try { facts.recall = await recallStatus(io, [...new Set([cwd, checkout])], recallIndex); } catch { facts.recall = null; }
247
348
 
248
349
  // #4375: already read and selected by the engine from the local ledger (pitfalls.mjs selectPitfalls); no I/O here
249
350
  await step("pitfalls", async () => {
@@ -367,9 +468,10 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
367
468
  for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
368
469
  }
369
470
  const found = Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, cand.filter((c) => c.src === s).length]));
370
- const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [] };
471
+ const recall = facts?.recall && typeof facts.recall === "object" ? { present: Boolean(facts.recall.present), reason: oneLine(facts.recall.reason ?? "", 80) } : null;
472
+ const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [], recall };
371
473
  if (!cand.length) return empty;
372
- const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts at this issue", pitfalls: "Recurring pitfalls in this repo (past rework)", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "Facts from earlier lanes and repo facts" };
474
+ const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts (this issue and the same subject)", pitfalls: "Recurring pitfalls in this repo (past rework)", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "Facts from earlier lanes and repo facts" };
373
475
  const head = `Context pack for #${facts.issue}`;
374
476
  const size = (s) => Buffer.byteLength(s, "utf8");
375
477
  const kept = [];
@@ -388,7 +490,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
388
490
  if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
389
491
  }
390
492
  const text = parts.join("\n");
391
- return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text), pitfalls: kept.filter((k) => k.pit).map((k) => k.pit) };
493
+ return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text), pitfalls: kept.filter((k) => k.pit).map((k) => k.pit), recall };
392
494
  }
393
495
 
394
496
  /** The pack text without its pitfalls section (#4375: pitfalls are local only and never posted). */
@@ -406,7 +508,7 @@ export function publicPackText(text) {
406
508
  /** The collapsed FORGE:CONTEXT issue record body (without the marker); pitfalls show as a count only (#4375). */
407
509
  export function renderContextRecord(pack, { mode, laneType: lt }) {
408
510
  const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources?.[s] ?? 0}/${pack.found?.[s] ?? 0}`).join(", ");
409
- return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}${pack.sources?.pitfalls ? " (pitfalls are local only, not shown)" : ""}\n\n~~~~text\n${noComments(publicPackText(pack.text)).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
511
+ return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}${pack.sources?.pitfalls ? " (pitfalls are local only, not shown)" : ""}${pack.recall && !pack.recall.present ? `\n\nRecall index: absent (${pack.recall.reason || "unknown"}); subject history comes from live search.` : ""}\n\n~~~~text\n${noComments(publicPackText(pack.text)).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
410
512
  }
411
513
 
412
514
  /**
@@ -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 } from "./context.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";
7
7
 
8
8
  const REPO = "o/r";
9
9
  const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
@@ -47,6 +47,7 @@ function fakes(o = {}) {
47
47
  calls.push(["gh", ...a]);
48
48
  const path = a.find((x) => String(x).startsWith("repos/"));
49
49
  if (o.ghFail) throw new Error("gh down");
50
+ if (a.includes("search/issues")) { const q = String(a.find((x) => String(x).startsWith("q=")) ?? ""); if (o.searchFail) throw new Error("HTTP 403 rate limit"); return o.search ? o.search(q) : ""; }
50
51
  let m;
51
52
  if ((m = /issues\/(\d+)\/timeline$/.exec(path))) return o.timeline ?? "https://api.github.com/repos/o/r 70\nhttps://api.github.com/repos/o/r 61\n";
52
53
  if ((m = /issues\/(\d+)\/comments$/.exec(path))) return (comments[m[1]] ?? []).map((c) => JSON.stringify(c)).join("\n");
@@ -56,6 +57,7 @@ function fakes(o = {}) {
56
57
  };
57
58
  const git = async (a) => {
58
59
  calls.push(["git", ...a]);
60
+ if (a[0] === "log" && a.some((x) => String(x).startsWith("--grep="))) return o.grepLog ?? "";
59
61
  if (a[0] === "for-each-ref") return "refs/forge/salvage/50/1700 abc1234\n";
60
62
  if (a[0] === "log") return log.map(([h, s, b, f]) => `\x1e${h}\x1f${s}\x1f${b}\x1f\n${f}\n`).join("");
61
63
  return "";
@@ -405,7 +407,7 @@ describe("#4375 recurring pitfalls in the context pack", () => {
405
407
  const r = renderContextRecord(p, { mode: "on", laneType: "first" });
406
408
  for (const x of ["no-unused-vars", "Recurring pitfalls", "src/*.ts", "devdocs/modules/lean.md is stale"]) assert.ok(!r.includes(x), x);
407
409
  assert.match(r, /pitfalls 2\/2/);
408
- assert.ok(r.includes("Prior attempts at this issue:") && r.includes("Merged PRs on the same symbols"), "the other sections stay");
410
+ assert.ok(r.includes("Prior attempts (this issue and the same subject):") && r.includes("Merged PRs on the same symbols"), "the other sections stay");
409
411
  });
410
412
 
411
413
  it("the cache key changes when the selected pitfall signatures change", () => {
@@ -488,3 +490,83 @@ describe("#4467: priorAttempts reads App comments in gh issue view shape", () =>
488
490
  assert.equal(priorAttempts("", [ghShape("someone")]), 0);
489
491
  });
490
492
  });
493
+
494
+ describe("#5085: subject history and recall status", () => {
495
+ const item = (n, title, extra = {}) => JSON.stringify({ number: n, title, state_reason: "completed", pr: false, merged: false, ...extra });
496
+ const SUBJ = { number: 50, title: "fix(fetch): TLS bypass misclassified as blocked", body: "Requests end as `tls.bypass_misclassified` in src/fetch/tls.ts.\n\n**Depends on**: #45" };
497
+
498
+ it("#5085: subject history surfaces a closed issue matched by an error code with no shared file", async () => {
499
+ const f = fakes({ search: (q) => (q.includes('"tls.bypass_misclassified"') ? [item(900, "Billing: classify TLS bypass"), item(45, "dep, already in graph"), item(50, "self")].join("\n") : "") });
500
+ const facts = await gatherContextFacts({ io: f.io, repo: REPO, issue: 50, iss: SUBJ, comments: [], cwd: "/wt", base: "origin/staging", sources: ["graph", "attempts"] });
501
+ const p = compileContextPack(facts);
502
+ assert.match(p.text, /- #900: \[issue\] completed "Billing: classify TLS bypass"; matched tls\.bypass_misclassified/);
503
+ assert.ok(!/- #45: \[issue\]/.test(p.text) && !/- #50: \[issue\]/.test(p.text), "self and graph links are deduped");
504
+ assert.ok(p.sources.attempts >= 1 && p.found.attempts >= 1, JSON.stringify(p.found));
505
+ assert.ok(f.calls.some((c) => c.includes("search/issues") && c.some((x) => String(x) === 'q=repo:o/r is:closed "tls.bypass_misclassified"')));
506
+ });
507
+
508
+ it("subjectTerms: codes, symbols, then distinctive title words; deterministic and capped", () => {
509
+ const t = subjectTerms({ title: "fix(context): recall prior attempts by subject, not only by issue number", body: "`ci.red` and `bin/lean/context.mjs` and `compileContextPack()` HTTP 403 <!-- `hidden.code` -->" });
510
+ assert.deepEqual(t, ["ci.red", "403", "compileContextPack", "recall", "prior", "attempts"]);
511
+ assert.ok(t.length <= 6);
512
+ assert.deepEqual(subjectTerms({ title: "", body: "" }), []);
513
+ });
514
+
515
+ it("matches title words and repo-wide git history; ranks by matched terms, caps the rows, keeps own attempts first", async () => {
516
+ const many = Array.from({ length: 12 }, (_, i) => item(800 + i, `thin content ${i}`)).join("\n");
517
+ const grepLog = `\x1e${"e".repeat(40)}\x1fMerge pull request #777 from o/fix-thin\x1fGuard thin content pages in billing\n\x1e${"f".repeat(40)}\x1fMerge pull request #778 from o/x\x1funrelated\n`;
518
+ const f = fakes({ search: () => many, grepLog });
519
+ const r = await subjectHistory({ io: f.io, repo: REPO, issue: 50, title: "Thin content billing guard", body: "", base: "origin/staging" });
520
+ assert.deepEqual(r.terms, ["thin", "content", "billing", "guard"].slice(0, 3));
521
+ assert.equal(r.rows.length, MAX_SUBJECT_ITEMS);
522
+ assert.ok(!r.rows.some((x) => x.number === 778), "a git hit must name the terms");
523
+ const g = f.calls.find((c) => c[0] === "git" && c[1] === "log");
524
+ assert.ok(g.includes("--all-match") && !g.includes("--"), "repo-wide, no pathspec");
525
+ assert.ok(f.calls.filter((c) => c.includes("search/issues")).length <= MAX_SUBJECT_QUERIES);
526
+ const both = await subjectHistory({ io: fakes({ search: () => item(777, "thin content PR", { pr: true, merged: true }), grepLog }).io, repo: REPO, issue: 50, title: "Thin content billing", body: "", base: "origin/staging" });
527
+ assert.deepEqual(both.rows.find((x) => x.number === 777), { number: 777, kind: "PR", status: "merged", title: "thin content PR", matched: ["thin", "content", "billing"] });
528
+ const facts = await gather(fakes({ search: () => many }), { iss: { ...ISS, title: "Thin content guard" }, sources: ["attempts"] });
529
+ const kinds = facts.attempts.map((a) => a.kind);
530
+ assert.ok(kinds.indexOf("subject") > kinds.lastIndexOf("salvage"), kinds.join(","));
531
+ });
532
+
533
+ it("ranks rows by matched-term count, then newest number", async () => {
534
+ const search = (q) => (q.includes('"a.code"') ? [item(901, "x"), item(950, "y")] : q.includes('"b.code"') ? [item(901, "x")] : []).join("\n");
535
+ const r = await subjectHistory({ io: fakes({ search }).io, repo: REPO, issue: 50, title: "", body: "`a.code` and `b.code`" });
536
+ assert.deepEqual(r.rows.map((x) => [x.number, x.matched]), [[901, ["a.code", "b.code"]], [950, ["a.code"]]]);
537
+ });
538
+
539
+ it("subject history is ranked and truncated within the byte budget like the other sources", async () => {
540
+ const many = Array.from({ length: 8 }, (_, i) => item(800 + i, `A long title about thin content billing regressions number ${i}`)).join("\n");
541
+ const facts = await gather(fakes({ search: () => many }), { iss: { ...ISS, title: "Thin content billing" }, sources: ["attempts"] });
542
+ const full = compileContextPack(facts);
543
+ assert.ok(full.found.attempts >= 8 + 3);
544
+ const small = compileContextPack(facts, { budgetBytes: 400 });
545
+ assert.ok(small.bytes <= 400 && small.truncated > 0, `${small.bytes} ${small.truncated}`);
546
+ assert.ok(small.text.includes("- #50 FORGE:STATE") && small.text.indexOf("- #50 FORGE:STATE") < (small.text.indexOf("- #80") >>> 0), "own attempts outrank subject rows");
547
+ });
548
+
549
+ it("a failed subject search is an error, never a throw, and an attempts-off pack makes no search", async () => {
550
+ const facts = await gather(fakes({ searchFail: true }), { iss: { ...ISS, title: "Thin content billing" }, sources: ["attempts"] });
551
+ assert.ok(facts.errors.some((e) => e.startsWith("attempts: subject search")), facts.errors.join("|"));
552
+ assert.ok(facts.attempts.some((a) => a.kind === "state"));
553
+ const g = fakes();
554
+ await gather(g, { sources: ["graph", "facts"] });
555
+ assert.ok(!g.calls.some((c) => c.includes("search/issues")));
556
+ });
557
+
558
+ it("a missing bin/recall.mjs or index is reported in the FORGE:CONTEXT record", async () => {
559
+ const absent = await gather(fakes(), { sources: ["graph"], recallIndex: "/idx/knowledge.jsonl" });
560
+ assert.deepEqual(absent.recall, { present: false, reason: "bin/recall.mjs missing" });
561
+ const p = compileContextPack(absent);
562
+ const r = renderContextRecord(p, { mode: "on", laneType: "first" });
563
+ assert.match(r, /Recall index: absent \(bin\/recall\.mjs missing\); subject history comes from live search\./);
564
+ assert.ok(!p.text.includes("Recall index"), "the status stays out of the pack text and its hash");
565
+ const noIndex = await gather({ io: { ...fakes().io, exists: async (x) => x === "/wt/bin/recall.mjs" } }, { sources: ["graph"], recallIndex: "/idx/knowledge.jsonl" });
566
+ assert.match(renderContextRecord(compileContextPack(noIndex), { mode: "on", laneType: "first" }), /Recall index: absent \(index ~\/\.forge\/index missing\)/);
567
+ const ok = await gather({ io: { ...fakes().io, exists: async (x) => x === "/wt/bin/recall.mjs" || x === "/idx/knowledge.jsonl" } }, { sources: ["graph"], recallIndex: "/idx/knowledge.jsonl" });
568
+ assert.deepEqual(ok.recall, { present: true, reason: "" });
569
+ assert.ok(!renderContextRecord(compileContextPack(ok), { mode: "on", laneType: "first" }).includes("Recall index"));
570
+ assert.equal(compileContextPack({ issue: 1, links: [], attempts: [], ledger: [], facts: [], recall: absent.recall }).recall.present, false, "the empty pack carries it too");
571
+ });
572
+ });
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Coupled files (#5079): forge.yaml `verification.coupled_files` is a block map `{when glob: required glob[, glob]}`,
5
+ * "if A changes, B must change" (a digest pin, a manifest, a runtime closure, CI wiring of a new test). A key prefixed
6
+ * `added:` fires only for files the lane adds. Deterministic, 0 LLM calls; a failure is `conformance.unimplemented`.
7
+ */
8
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
9
+ import { globToRegex } from "../../scripts/ci-local/lib.mjs";
10
+
11
+ const ADDED = "added:";
12
+ const norm = (p) => String(p ?? "").trim().replace(/^\.\//, "");
13
+
14
+ /** Rules `{when, requires[], addedOnly}` from the config map; entries with no usable glob are dropped, never thrown on. */
15
+ export function parseCoupled(map) {
16
+ if (!map || typeof map !== "object" || Array.isArray(map)) return [];
17
+ const rules = [];
18
+ for (const [key, val] of Object.entries(map)) {
19
+ const k = String(key).trim(), addedOnly = k.startsWith(ADDED);
20
+ const when = norm(addedOnly ? k.slice(ADDED.length) : k);
21
+ const requires = String(val ?? "").split(",").map((g) => norm(g.replace(/^(["'])(.*)\1$/, "$2"))).filter(Boolean);
22
+ if (when && requires.length) rules.push({ when, requires, addedOnly });
23
+ }
24
+ return rules;
25
+ }
26
+
27
+ /** Failures for each rule whose trigger files changed while none of a required glob's files did. */
28
+ export function coupledFailures({ rules = [], changedFiles = [], addedFiles = [] } = {}) {
29
+ const changed = changedFiles.map(norm), added = addedFiles.map(norm), out = [];
30
+ for (const r of rules) {
31
+ const re = globToRegex(r.when);
32
+ const hits = (r.addedOnly ? added : changed).filter((f) => re.test(f));
33
+ if (!hits.length) continue;
34
+ const missing = r.requires.filter((g) => { const gre = globToRegex(g); return !changed.some((f) => gre.test(f)); });
35
+ if (missing.length) out.push({ code: OUTCOME_CODES.CONFORMANCE_UNIMPLEMENTED, kind: "coupled_file_missing", detail: `${r.addedOnly ? "added" : "changed"} ${hits.slice(0, 3).join(", ")} (forge.yaml verification.coupled_files) but no change to ${missing.join(", ")}` });
36
+ }
37
+ return out;
38
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseCoupled, coupledFailures } from "./coupled.mjs";
4
+ import { parseVerify } from "./verify.mjs";
5
+ import { planPrecheck } from "./precheck.mjs";
6
+ import { parseTestMap } from "./testmap.mjs";
7
+ import { precheck } from "./conformance.mjs";
8
+
9
+ const rules = parseCoupled({
10
+ "infra/ssh-guard/bootstrap-body.sh": "infra/ssh-guard/launcher.sh, infra/ssh-guard/MANIFEST.sha256",
11
+ "added:scripts/tests/test_*.py": ".github/workflows/ci.yml",
12
+ });
13
+
14
+ describe("coupled files (#5079)", () => {
15
+ it("coupled files: a changed file without its pinned counterpart fails", () => {
16
+ const f = coupledFailures({ rules, changedFiles: ["infra/ssh-guard/bootstrap-body.sh", "infra/ssh-guard/launcher.sh"] });
17
+ assert.equal(f.length, 1);
18
+ assert.equal(f[0].kind, "coupled_file_missing");
19
+ assert.equal(f[0].code, "conformance.unimplemented");
20
+ assert.match(f[0].detail, /MANIFEST\.sha256/);
21
+ });
22
+ it("coupled files: passes when every counterpart changes, or the trigger did not", () => {
23
+ assert.deepEqual(coupledFailures({ rules, changedFiles: ["infra/ssh-guard/bootstrap-body.sh", "infra/ssh-guard/launcher.sh", "infra/ssh-guard/MANIFEST.sha256"] }), []);
24
+ assert.deepEqual(coupledFailures({ rules, changedFiles: ["README.md"] }), []);
25
+ });
26
+ it("coupled files: an added: rule fires on a new test file only", () => {
27
+ const t = "scripts/tests/test_x.py";
28
+ assert.equal(coupledFailures({ rules, changedFiles: [t], addedFiles: [t] }).length, 1);
29
+ assert.deepEqual(coupledFailures({ rules, changedFiles: [t], addedFiles: [] }), []);
30
+ assert.deepEqual(coupledFailures({ rules, changedFiles: [t, ".github/workflows/ci.yml"], addedFiles: [t] }), []);
31
+ });
32
+ it("coupled files: malformed config yields no rules", () => {
33
+ assert.deepEqual(parseCoupled(null), []);
34
+ assert.deepEqual(parseCoupled(["a"]), []);
35
+ assert.deepEqual(parseCoupled({ "": "x", a: "" }), []);
36
+ });
37
+ it("coupled files: precheck() carries the failures; absent they change nothing", () => {
38
+ const c = { requirements: [], deliverables: [], validate: [] };
39
+ const failures = coupledFailures({ rules, changedFiles: ["infra/ssh-guard/bootstrap-body.sh"] });
40
+ assert.equal(precheck({ contract: c, result: {}, changedFiles: [], coupledFailures: failures }).ok, false);
41
+ assert.equal(precheck({ contract: c, result: {}, changedFiles: [] }).ok, true);
42
+ });
43
+ it("coupled files: test-map entries select the touched suite and formatter per path (R3, R6)", () => {
44
+ const testMap = parseTestMap(JSON.stringify({ version: 1, entries: [
45
+ { command: "bash infra/ssh-guard/validate-manifest.sh", globs: ["infra/ssh-guard/*.sh"] },
46
+ { command: "black --check services/api && isort --check services/api", globs: ["services/api/**"] },
47
+ ] }));
48
+ const p = planPrecheck({ testMap, files: ["infra/ssh-guard/check-guard-deps.sh", "services/api/x.py"], learned: [] });
49
+ assert.deepEqual(p.commands, ["bash infra/ssh-guard/validate-manifest.sh", "black --check services/api && isort --check services/api"]);
50
+ assert.deepEqual(planPrecheck({ testMap, files: ["docs/a.md"], learned: [] }).commands, []);
51
+ });
52
+ it("coupled files: verify.precheck_block is opt-in", () => {
53
+ assert.equal(parseVerify({ precheck_block: "true" }).precheckBlock, true);
54
+ assert.equal("precheckBlock" in parseVerify({}), false);
55
+ assert.equal("precheckBlock" in parseVerify({ precheck_block: "yes" }), false);
56
+ });
57
+ });
@@ -13,6 +13,7 @@
13
13
  * Not yet called in production: the issue-watch stage (#4765) is meant to call `settle` once per escalation (key: the escalation comment id).
14
14
  */
15
15
  import { parseForgeYaml } from "../forge-utils.mjs";
16
+ import { READONLY_PROD_RE, EXTERNAL_NEED_RE } from "./conformance.mjs";
16
17
  import { foldDecisions, recordDecisions as recordToRegister } from "./decisions.mjs";
17
18
 
18
19
  export const VETO_LABEL = "forge:decision-veto";
@@ -80,7 +81,8 @@ const POSTDEPLOY_RE = /\b(all (remaining )?(items|checks|criteria) (are|is) post
80
81
  /**
81
82
  * Splits an escalation into decision and external parts from facts. `{terminalCode, blockType, text}`, `matrix` from
82
83
  * loadPolicy, `retryable` a set of codes recovery owns. Returns `{kind, decisions:[text], external:[{class, text}]}` with
83
- * kind `recovery | evidence | post-deploy | external | decision | mixed`.
84
+ * kind `recovery | evidence | post-deploy | host | external | decision | mixed`. #5080: `host` is read-only production access with no
85
+ * credential/spend/account/customer need: host work (a `host-task`), never a human ask.
84
86
  */
85
87
  export function splitEscalation({ terminalCode = null, blockType = null, text = "" } = {}, matrix = [], { retryable = [] } = {}) {
86
88
  const body = String(text ?? "");
@@ -88,6 +90,7 @@ export function splitEscalation({ terminalCode = null, blockType = null, text =
88
90
  if (terminalCode && new Set(retryable).has(terminalCode)) return out("recovery");
89
91
  if (terminalCode === "conformance.unimplemented" || EVIDENCE_RE.test(body)) return out("evidence");
90
92
  if (POSTDEPLOY_RE.test(body)) return out("post-deploy");
93
+ if (READONLY_PROD_RE.test(body) && !EXTERNAL_NEED_RE.test(body)) return out("host");
91
94
  const klass = (t) => matrix.find((m) => m.re.test(t))?.class ?? null;
92
95
  if (blockType === "credentials" && matrix.some((m) => m.class === "credentials")) return out("external", [], [{ class: "credentials", text: body.slice(0, 200) }]);
93
96
  const lines = body.split("\n").map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, "").trim()).filter(Boolean);
@@ -177,6 +180,7 @@ export async function settle(item, policy, deps = {}, opts = {}) {
177
180
  if (s.kind === "recovery") return { ...row, reason: "recovery owns this code" };
178
181
  if (s.kind === "external") return { ...row, action: "external", kept: true, class: s.external[0]?.class, reason: `skip: external (${s.external[0]?.class})` };
179
182
  if (s.kind === "evidence") return { ...row, action: "evidence-needed" };
183
+ if (s.kind === "host") return { ...row, action: "host-task" };
180
184
  if (s.kind === "post-deploy") return { ...row, action: "post-deploy-only" };
181
185
  const key = `Escalation: ${item.escalationId ?? "-"}`;
182
186
  const precedent = foldDecisions(deps.records ?? []).filter((d) => d.status !== "superseded");
@@ -35,6 +35,14 @@ test("#4766 loadPolicy: defaults, fallback order, matrix schema", () => {
35
35
  assert.equal(loadPolicy({ io: files({}) }).policyPath, null);
36
36
  });
37
37
 
38
+ test("#5080 splitEscalation: a credentials block that is only read-only production access is host work; real credentials stay external", () => {
39
+ const matrix = loadPolicy({ io: files({ "AGENTS.md": "a" }) }).matrix;
40
+ const host = splitEscalation({ text: "D1 is gated by K1 on read-only production telemetry: a host-only prod_monitor capability this sandbox lacks", blockType: "credentials", terminalCode: "builder.blocked" }, matrix);
41
+ assert.equal(host.kind, "host");
42
+ assert.deepEqual(host.external, []);
43
+ assert.equal(splitEscalation({ text: "needs the production API key to read prod data", blockType: "credentials", terminalCode: "builder.blocked" }, matrix).kind, "external");
44
+ });
45
+
38
46
  test("#4766 splitEscalation: the six AlterLab fixtures", () => {
39
47
  const { matrix } = policy();
40
48
  for (const [n, f] of Object.entries(FIX)) assert.equal(splitEscalation(f, matrix).kind, f.want, `#${n}`);
@@ -158,6 +166,19 @@ test("#4766 tally, telemetry mapping and the batch report 'decided' section", ()
158
166
  assert.deepEqual(formatOutcomeLists([{ issue: 7, outcome: "merged" }]), []);
159
167
  });
160
168
 
169
+ test("#5078 the batch report lists follow-ups appended, grouped by issue across rows, the rollup named", () => {
170
+ const lines = formatOutcomeLists([
171
+ { issue: 7, outcome: "merged", followups: [{ issue: 900, class: "post-deploy", count: 2 }, { issue: 901, class: "engineering", count: 1 }] },
172
+ { issue: 8, outcome: "merged", followups: [{ issue: 900, class: "post-deploy", count: 1 }] },
173
+ { issue: 9, outcome: "merged", followups: [{ issue: 950, class: "external", count: 3 }] },
174
+ ]);
175
+ const fu = lines.filter((l) => l.startsWith("follow-ups appended:"));
176
+ assert.equal(fu.length, 1);
177
+ assert.equal(fu[0], "follow-ups appended: #900 (post-deploy rollup: 3 item(s) from #7,#8) #901 (engineering: 1 item(s) from #7) #950 (external aggregate: 3 item(s) from #9)");
178
+ assert.ok(!lines.some((l) => /issues filed/.test(l)));
179
+ assert.deepEqual(formatOutcomeLists([{ issue: 7, outcome: "merged", followups: [] }]), []);
180
+ });
181
+
161
182
  test("#4826 docs say 'not yet wired' exactly while no production module calls settle", async () => {
162
183
  const { readFileSync, readdirSync } = await import("node:fs");
163
184
  const dir = new URL(".", import.meta.url);
@@ -19,6 +19,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
19
  import { tmpdir } from "node:os";
20
20
  import { join } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
+ import { listMilestones } from "./lane.mjs";
22
23
 
23
24
  export const SUMMARY_MARKER = "FORGE:DRIFT";
24
25
  const SUMMARY_RE = /<!-- FORGE:DRIFT (\{[\s\S]*?\}) -->/;
@@ -134,9 +135,9 @@ export function heldBy(files, flagged) {
134
135
  return null;
135
136
  }
136
137
 
137
- /** {number, description} of the milestone titled `title`, or null. One `gh api` read. */
138
+ /** {number, description} of the milestone titled `title`, or null. One paginated read (lane.mjs `listMilestones`, every page). */
138
139
  export async function readMilestone({ gh, repo, title }) {
139
- const list = JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=all&per_page=100`])) || "[]");
140
+ const list = await listMilestones({ gh, repo, state: "all" });
140
141
  const m = arr(list).find((x) => x?.title === title);
141
142
  return m ? { number: m.number, description: m.description ?? "" } : null;
142
143
  }
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { tmpDir } from "../tests/_tmp.mjs";
7
7
  import { join } from "node:path";
8
- import { foldSummary, emptySummary, parseSummary, renderSummary, crossingIssues, fileCrossings, flaggedPackages, heldBy, makeDrift } from "./drift.mjs";
8
+ import { foldSummary, emptySummary, parseSummary, renderSummary, crossingIssues, fileCrossings, flaggedPackages, heldBy, makeDrift, readMilestone } from "./drift.mjs";
9
9
  import { orchestrate } from "./orchestrate.mjs";
10
10
  import { readDrift } from "../engine-cli.mjs";
11
11
  import { affectedFiles } from "../orchestrate-preflight.mjs";
@@ -221,3 +221,25 @@ describe("readDrift (forge#4161)", () => {
221
221
  assert.deepEqual(withYaml("orchestrate:\n drift:\n file_lines: 600\n", readDrift), { pairGrowth: null, packageLines: null, fileLines: 600, blockDispatch: false, generatedGlobs: [] });
222
222
  });
223
223
  });
224
+
225
+ /** #5084: a GitHub-like milestones endpoint: pages of 100; without --paginate only page 1; `--jq '.[] | @json'` prints one object per line. */
226
+ const pagedMilestones = (all) => (a) => {
227
+ const state = /state=(\w+)/.exec(a[1])?.[1] ?? "open";
228
+ const items = all.filter((m) => state === "all" || m.state === state);
229
+ const pages = []; for (let i = 0; i < items.length; i += 100) pages.push(items.slice(i, i + 100));
230
+ if (!a.includes("--paginate")) return JSON.stringify(pages[0] ?? []);
231
+ if (a.includes("--jq")) return items.map((m) => JSON.stringify(m)).join("\n") + "\n";
232
+ return pages.map((p) => JSON.stringify(p)).join("");
233
+ };
234
+ const manyMilestones = (target, n = 120) => [...Array.from({ length: n }, (_, i) => ({ number: i + 1, title: `M ${i + 1}`, state: i % 2 ? "open" : "closed", description: "" })), { number: n + 1, ...target }];
235
+
236
+ describe("readMilestone pagination (#5084)", () => {
237
+ it("(#5084) readMilestone finds a milestone on page 2", async () => {
238
+ const list = pagedMilestones(manyMilestones({ title: "Search & Research API", state: "open", description: "late" }));
239
+ const calls = [];
240
+ const gh = async (a) => { calls.push(a); return list(a); };
241
+ assert.deepEqual(await readMilestone({ gh, repo: "o/r", title: "Search & Research API" }), { number: 121, description: "late" });
242
+ assert.equal(calls[0][1], "repos/o/r/milestones?state=all&per_page=100");
243
+ assert.equal(await readMilestone({ gh, repo: "o/r", title: "Missing" }), null);
244
+ });
245
+ });
@@ -359,11 +359,12 @@ export function makeLeanIo({ repo, subagentModel = readSubagentModel() }) {
359
359
  * File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
360
360
  * append, create, claim marker, duplicate re-check; one issue per class, #4664). items: [{key, why, class?}]; the key is the
361
361
  * item's identity (column 5, column 1 is `-`). milestone / defaultMilestone: the parent's and forge.yaml followups.default_milestone; lane: the release lane of the post-deploy rollup (#4724).
362
+ * batchId: the external aggregate's batch (FORGE_BATCH_ID); rollupLabel: forge.yaml followups.rollup_issue_label (FORGE_ROLLUP_LABEL) (#5078).
362
363
  * Returns {key: issueNumber}; throws on exit 2/3/4.
363
364
  */
364
- async fileFollowups({ parent, items, milestone = null, defaultMilestone = null, lane = null }) {
365
+ async fileFollowups({ parent, items, milestone = null, defaultMilestone = null, lane = null, batchId = null, rollupLabel = null }) {
365
366
  const tsv = items.map((i) => `-\t${i.why}\t\t${i.class ?? ""}\t${i.key}`).join("\n") + "\n";
366
- const env = { ...process.env, ...(milestone ? { FORGE_FOLLOWUP_MILESTONE: String(milestone) } : {}), ...(defaultMilestone ? { FORGE_FOLLOWUPS_DEFAULT_MILESTONE: String(defaultMilestone) } : {}), ...(lane ? { FORGE_RELEASE_LANE: String(lane) } : {}) }; // #4724: the post-deploy rollup is one per release lane
367
+ const env = { ...process.env, ...(milestone ? { FORGE_FOLLOWUP_MILESTONE: String(milestone) } : {}), ...(defaultMilestone ? { FORGE_FOLLOWUPS_DEFAULT_MILESTONE: String(defaultMilestone) } : {}), ...(lane ? { FORGE_RELEASE_LANE: String(lane) } : {}), ...(batchId ? { FORGE_BATCH_ID: String(batchId) } : {}), ...(rollupLabel ? { FORGE_ROLLUP_LABEL: String(rollupLabel) } : {}) }; // #4724: the post-deploy rollup is one per release lane
367
368
  const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000, env);
368
369
  return followupMap(out, items);
369
370
  },