forgedock 1.43.1 → 1.45.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.
- package/bin/engine/outcome-codes.mjs +2 -2
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine-cli.mjs +50 -11
- package/bin/forgedock.mjs +50 -24
- package/bin/gc.mjs +3 -1
- package/bin/hooks/pre-tool-use.mjs +54 -3
- package/bin/lean/autochildren.mjs +13 -5
- package/bin/lean/autochildren.test.mjs +36 -18
- package/bin/lean/batch.test.mjs +46 -0
- package/bin/lean/conformance.mjs +107 -16
- package/bin/lean/conformance.test.mjs +126 -13
- package/bin/lean/decision-engine.mjs +1 -1
- package/bin/lean/decision-engine.test.mjs +11 -0
- package/bin/lean/execution.mjs +7 -4
- package/bin/lean/execution.test.mjs +21 -0
- package/bin/lean/gatewatch.test.mjs +5 -5
- package/bin/lean/lane.mjs +22 -4
- package/bin/lean/lane.test.mjs +33 -1
- package/bin/lean/lean.mjs +49 -16
- package/bin/lean/lean.test.mjs +66 -0
- package/bin/lean/orchestrate-blocked.test.mjs +35 -0
- package/bin/lean/orchestrate-children.test.mjs +16 -4
- package/bin/lean/orchestrate.mjs +61 -13
- package/bin/lean/pmap.mjs +17 -0
- package/bin/lean/pmap.test.mjs +27 -0
- package/bin/lean/postdeploy-check.test.mjs +3 -1
- package/bin/lean/preflight.mjs +8 -3
- package/bin/lean/preflight.test.mjs +13 -1
- package/bin/lean/prodwatch-triage.mjs +6 -0
- package/bin/lean/prodwatch-triage.test.mjs +14 -1
- package/bin/lean/prodwatch.mjs +1 -3
- package/bin/lean/prompts.mjs +1 -1
- package/bin/lean/recovery.test.mjs +21 -4
- package/bin/lean/retry.mjs +11 -0
- package/bin/lean/retry.test.mjs +16 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +15 -4
- package/bin/lean/sandbox-remote.mjs +19 -7
- package/bin/lean/sched.mjs +25 -2
- package/bin/lean/sched.test.mjs +58 -1
- package/bin/lean/selftest-live.mjs +56 -31
- package/bin/lean/selftest-live.test.mjs +48 -6
- package/bin/lean/selftest-nightly.mjs +7 -3
- package/bin/lean/selftest-nightly.test.mjs +24 -2
- package/bin/lean/selftest-static.mjs +18 -11
- package/bin/lean/selftest-static.test.mjs +8 -0
- package/bin/lean/signoff.mjs +16 -3
- package/bin/lean/signoff.test.mjs +52 -1
- package/bin/pool/agent.mjs +3 -3
- package/bin/pool/cli.mjs +29 -1
- package/bin/pool/router.mjs +17 -6
- package/commands/orchestrate.md +5 -2
- package/docs/CONFIG.md +10 -4
- package/docs/DECISIONS.md +5 -1
- package/docs/OUTCOME-CODES.md +2 -2
- package/docs/TELEMETRY.md +1 -1
- package/package.json +1 -1
- package/scripts/plan-validate-live.mjs +15 -8
|
@@ -60,8 +60,14 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
60
60
|
return { status: res.status, ok: res.ok, data, retryAfter: res.headers?.get?.("retry-after") ?? null };
|
|
61
61
|
};
|
|
62
62
|
const fail = (what, r) => {
|
|
63
|
-
|
|
63
|
+
// #5053: a 401 names the hop that answered it (router or agent; the pool tags the body), never the token.
|
|
64
|
+
const hop = r.status === 401 && ["router", "agent"].includes(r.data?.hop) ? r.data.hop : null;
|
|
65
|
+
const who = hop === "agent" && typeof r.data?.agent === "string" ? `agent ${r.data.agent.slice(0, 64)}` : hop;
|
|
66
|
+
const reason = hop && typeof r.data?.reason === "string" ? r.data.reason.replace(/^(router|agent) auth: /, "").replace(/[^\x20-\x7e]/g, "").slice(0, 80) : "";
|
|
67
|
+
const e = new Error(`sandbox ${what}: HTTP ${r.status}${who ? ` (${who} auth${reason ? `: ${reason}` : ""})` : ""}`);
|
|
64
68
|
e.status = r.status;
|
|
69
|
+
if (hop) e.hop = hop;
|
|
70
|
+
if (reason) e.reason = reason;
|
|
65
71
|
if ([502, 503, 504].includes(r.status)) e.transient = true; // #4672: a router restart or gateway blip, retried by the lane poll
|
|
66
72
|
if (r.status === 429 || r.status === 503) { e.retryable = true; const n = Number(r.retryAfter); if (Number.isFinite(n) && n > 0) e.retryInSeconds = n; }
|
|
67
73
|
return e;
|
|
@@ -74,12 +80,12 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
74
80
|
async status(id) {
|
|
75
81
|
const r = await call("GET", p(id));
|
|
76
82
|
if (r.status === 404) return "gone";
|
|
77
|
-
if (!r.ok) throw fail("status", r);
|
|
83
|
+
if (!r.ok) throw Object.assign(fail("status", r), r.status === 401 ? { transient: true } : {}); // #5053: the sandbox launched, so a 401 is retried like an unreachable pool
|
|
78
84
|
const s = String(r.data?.state ?? "");
|
|
79
85
|
return s === "running" || s === "pending" || s === "starting" ? "running" : s === "gone" ? "gone" : "exited";
|
|
80
86
|
},
|
|
81
87
|
async logs(id, cursor = 0) { const r = await call("GET", `${p(id)}/logs?cursor=${encodeURIComponent(cursor)}`); return r.ok ? { text: String(r.data?.text ?? ""), cursor: r.data?.cursor ?? cursor } : { text: "", cursor }; },
|
|
82
|
-
async result(id) { const r = await call("GET", `${p(id)}/result`); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
|
|
88
|
+
async result(id) { const r = await call("GET", `${p(id)}/result`); if (r.status === 401) throw Object.assign(fail("result", r), { transient: true }); return r.ok && r.data && typeof r.data === "object" ? r.data : null; },
|
|
83
89
|
async usage(id) { const r = await call("GET", `${p(id)}/usage`); return r.ok && Number.isFinite(Number(r.data?.minutes)) ? { minutes: Number(r.data.minutes), ...(typeof r.data.agent === "string" && r.data.agent ? { agent: r.data.agent } : {}) } : null; }, // agent: #4549, optional
|
|
84
90
|
async stop(id) { const r = await call("POST", `${p(id)}/stop`); if (r.ok) return true; if (r.status === 404 || r.status === 405) return false; throw fail("stop", r); },
|
|
85
91
|
async destroy(id) { const r = await call("DELETE", p(id)); if (r.ok || r.status === 404) return true; throw fail("destroy", r); },
|
|
@@ -88,19 +94,23 @@ export function httpTransport({ endpoint, token = null, fetchFn = globalThis.fet
|
|
|
88
94
|
async capacity() {
|
|
89
95
|
// `free` (#4540) and agent liveness `online`/`total` (#4547) are each optional; none valid is unknown.
|
|
90
96
|
const ok = (v) => Number.isInteger(v) && v >= 0;
|
|
91
|
-
let out = {};
|
|
97
|
+
let out = {}, reached = false, lastErr = null;
|
|
92
98
|
try {
|
|
93
99
|
const r = await call("GET", "/status");
|
|
100
|
+
reached = true;
|
|
94
101
|
if (r.ok) { const { free, online, total } = r.data ?? {}; out = { ...(ok(free) ? { free } : {}), ...(ok(online) ? { online } : {}), ...(ok(total) ? { total } : {}) }; }
|
|
95
|
-
} catch { out = {}; }
|
|
102
|
+
} catch (e) { out = {}; lastErr = e; }
|
|
96
103
|
if (out.online === undefined) {
|
|
97
104
|
// #4788: a router without /status liveness: online = agents answering and not paused, as bin/pool/router.mjs status() does.
|
|
98
105
|
try {
|
|
99
106
|
const r = await call("GET", "/agents");
|
|
107
|
+
reached = true;
|
|
100
108
|
const list = r.ok && Array.isArray(r.data?.agents) ? r.data.agents : null;
|
|
101
109
|
if (list) out = { ...out, online: list.filter((a) => a?.ok === true && !a.paused).length, total: list.length };
|
|
102
|
-
} catch { /* liveness stays unknown */ }
|
|
110
|
+
} catch (e) { lastErr = e; /* liveness stays unknown */ }
|
|
103
111
|
}
|
|
112
|
+
// #5048 (release review REG-2): a router that answered neither call is unreachable: throw, so the pool is marked down
|
|
113
|
+
if (!reached) throw Object.assign(new Error(`pool router unreachable: ${String(lastErr?.message ?? lastErr).slice(0, 160)}`), { unreachable: true });
|
|
104
114
|
return Object.keys(out).length ? out : null;
|
|
105
115
|
},
|
|
106
116
|
};
|
|
@@ -218,7 +228,9 @@ export function remoteProvider({ batch, repo, settings = {}, name = "remote", tr
|
|
|
218
228
|
return r ?? null;
|
|
219
229
|
},
|
|
220
230
|
async capacity() {
|
|
221
|
-
|
|
231
|
+
// #5048: no capacity() is unknown (null); a transport error propagates, so the caller can report the pool down
|
|
232
|
+
if (typeof transport.capacity !== "function") return null;
|
|
233
|
+
return (await transport.capacity()) ?? null;
|
|
222
234
|
},
|
|
223
235
|
/** #4612: a graceful stop at the lane's checkpoint; false when unsupported or failed. Never destroys the sandbox. */
|
|
224
236
|
async stop(h) {
|
package/bin/lean/sched.mjs
CHANGED
|
@@ -144,12 +144,14 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
|
|
|
144
144
|
}
|
|
145
145
|
const obj = (x) => (x && typeof x === "object" && !Array.isArray(x) ? x : {});
|
|
146
146
|
const weights = Object.fromEntries(Object.entries(obj(o?.weights)).filter(([, n]) => posInt(n)));
|
|
147
|
-
return { v: 1, cap: posInt(o?.cap), weights, seq: Number.isInteger(o?.seq) ? o.seq : 0, slots: obj(o?.slots), queue: obj(o?.queue) };
|
|
147
|
+
return { v: 1, cap: posInt(o?.cap), weights, seq: Number.isInteger(o?.seq) ? o.seq : 0, slots: obj(o?.slots), queue: obj(o?.queue), remote: obj(o?.remote), pool: obj(o?.pool) };
|
|
148
148
|
};
|
|
149
149
|
// Drop queued entries of dead dispatchers, and slots whose dispatcher and lane are both gone.
|
|
150
150
|
const reclaim = (s) => {
|
|
151
151
|
for (const [k, e] of Object.entries(s.queue)) if (!alive(e.pid)) delete s.queue[k];
|
|
152
152
|
for (const [k, e] of Object.entries(s.slots)) if (!alive(e.pid) && !(e.lanePid && alive(e.lanePid))) delete s.slots[k];
|
|
153
|
+
for (const [k, e] of Object.entries(s.remote)) if (!alive(e?.pid)) delete s.remote[k]; // #4964
|
|
154
|
+
for (const [k, e] of Object.entries(s.pool)) if (!alive(e?.pid)) delete s.pool[k];
|
|
153
155
|
};
|
|
154
156
|
const mutate = (fn) => {
|
|
155
157
|
lock();
|
|
@@ -200,7 +202,18 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
|
|
|
200
202
|
/** Queue entries `[{key, batch, repo, issue, priority}]` (their FIFO position is kept on a re-offer). */
|
|
201
203
|
offer: (entries) => { try { mutate((s) => { for (const e of entries) enqueue(s, e.key, e); }); } catch { /* fail-open */ } },
|
|
202
204
|
withdraw: (keys) => { if (!keys.length) return; try { mutate((s) => { for (const k of keys) delete s.queue[k]; }); } catch { /* fail-open */ } },
|
|
203
|
-
release: (key) => { try { mutate((s) => { delete s.slots[key]; delete s.queue[key]; }); } catch { /* fail-open */ } },
|
|
205
|
+
release: (key) => { try { mutate((s) => { delete s.slots[key]; delete s.queue[key]; delete s.remote[key]; }); } catch { /* fail-open */ } },
|
|
206
|
+
/** #4964: publish a running remote (pool) lane for `sched show`; it never takes a host slot. */
|
|
207
|
+
noteRemote: (key, entry = {}) => {
|
|
208
|
+
try {
|
|
209
|
+
mutate((s) => { s.remote[key] = { pid, batch: entry.batch ?? null, repo: entry.repo ?? null, issue: entry.issue ?? null, sandbox: entry.sandbox ?? null, agent: entry.agent ?? null, at: now() }; });
|
|
210
|
+
} catch { /* fail-open */ }
|
|
211
|
+
},
|
|
212
|
+
dropRemote: (key) => { try { mutate((s) => { delete s.remote[key]; }); } catch { /* fail-open */ } },
|
|
213
|
+
/** #4964: this batch's pool is unreachable (`{reason}`), or null to clear it. */
|
|
214
|
+
notePool: (batch, state) => {
|
|
215
|
+
try { mutate((s) => { if (state) s.pool[batch] = { pid, state: "unreachable", reason: state.reason ?? null, at: now() }; else delete s.pool[batch]; }); } catch { /* fail-open */ }
|
|
216
|
+
},
|
|
204
217
|
setLanePid: (key, lanePid) => { try { mutate((s) => { if (s.slots[key] && posInt(lanePid)) s.slots[key].lanePid = lanePid; }); } catch { /* fail-open */ } },
|
|
205
218
|
/** Record a lane this dispatcher re-attached to: it runs already, so the cap does not gate it. */
|
|
206
219
|
adoptSlot: (key, entry = {}) => {
|
|
@@ -231,6 +244,16 @@ export function openSched(dir = schedDir(), { now = () => Date.now(), pid = proc
|
|
|
231
244
|
weights: { ...s.weights },
|
|
232
245
|
slots: Object.entries(s.slots).map(([key, e]) => ({ key, ...e })),
|
|
233
246
|
queue: grantOrder(s).map(([key, e]) => ({ key, ...e })),
|
|
247
|
+
remote: Object.entries(s.remote).map(([key, e]) => ({ key, ...e })),
|
|
248
|
+
pool: Object.entries(s.pool).map(([batch, e]) => ({ batch, ...e })),
|
|
234
249
|
})),
|
|
235
250
|
};
|
|
236
251
|
}
|
|
252
|
+
|
|
253
|
+
/** #4964: `sched show` lines for remote (pool) lanes and an unreachable pool; empty when there are none. */
|
|
254
|
+
export function remoteLines(st) {
|
|
255
|
+
const out = (st?.remote ?? []).map((e) => ` remote #${e.issue} ${e.repo ?? "-"} batch ${e.batch ?? "-"} sandbox ${e.sandbox ?? "-"} agent ${e.agent ?? "-"} dispatcher ${e.pid}`);
|
|
256
|
+
const down = st?.pool ?? [];
|
|
257
|
+
if (down.length) out.push(`pool: unreachable (${down.map((e) => `batch ${e.batch}${e.reason ? `: ${e.reason}` : ""}`).join("; ")})`);
|
|
258
|
+
return out;
|
|
259
|
+
}
|
package/bin/lean/sched.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
-
import { grantOrder, openSched, priorityFromLabels } from "./sched.mjs";
|
|
8
|
+
import { grantOrder, openSched, priorityFromLabels, remoteLines } from "./sched.mjs";
|
|
9
9
|
import { orchestrate, estimateLaneMinutes, laneHistory, formatEstimateSummary, EST_DEFAULT_MIN, POST_DEPLOY_EST_MIN } from "./orchestrate.mjs";
|
|
10
10
|
import { schedFromCli } from "../engine-cli.mjs";
|
|
11
11
|
|
|
@@ -512,3 +512,60 @@ describe("live local lane cap (#4777)", () => {
|
|
|
512
512
|
assert.equal(peak, 2);
|
|
513
513
|
});
|
|
514
514
|
});
|
|
515
|
+
|
|
516
|
+
describe("sched show lists remote (pool) lanes (#4964)", () => {
|
|
517
|
+
const show = async (sched) => { const lines = []; const l = console.log; console.log = (m) => lines.push(String(m)); try { const st = await schedFromCli(["show"], { sched }); return { st, lines }; } finally { console.log = l; } };
|
|
518
|
+
|
|
519
|
+
it("remote lanes get their own rows and a remote N count, and take no host slot or cap", async () => {
|
|
520
|
+
const { handle } = world();
|
|
521
|
+
const a = handle(501), b = handle(502);
|
|
522
|
+
a.setCap(1);
|
|
523
|
+
a.noteRemote("B#2", { batch: "B", repo: "o/r", issue: 2, sandbox: "fdrsbx-B-2", agent: "legion" });
|
|
524
|
+
a.noteRemote("B#3", { batch: "B", repo: "o/r", issue: 3, sandbox: "fdrsbx-B-3" });
|
|
525
|
+
const { st, lines } = await show(b);
|
|
526
|
+
assert.equal(st.slots.length, 0);
|
|
527
|
+
assert.equal(st.remote.length, 2);
|
|
528
|
+
assert.ok(lines.some((m) => /running 0, queued 0, remote 2$/.test(m)), lines.join("\n"));
|
|
529
|
+
assert.ok(lines.includes(" remote #2 o/r batch B sandbox fdrsbx-B-2 agent legion dispatcher 501"), lines.join("\n"));
|
|
530
|
+
assert.equal(b.tryAcquire("C#1", {}, null).ok, true, "cap 1 is still free: remote lanes hold no slot");
|
|
531
|
+
a.dropRemote("B#2");
|
|
532
|
+
assert.deepEqual(b.list().remote.map((r) => r.key), ["B#3"]);
|
|
533
|
+
a.release("B#3");
|
|
534
|
+
assert.equal(b.list().remote.length, 0);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("a dead dispatcher's remote lane and pool notice are reclaimed", () => {
|
|
538
|
+
const { handle, live } = world();
|
|
539
|
+
const a = handle(511), b = handle(512);
|
|
540
|
+
a.noteRemote("B#2", { batch: "B", issue: 2 });
|
|
541
|
+
a.notePool("B", { reason: "capacity probe failed" });
|
|
542
|
+
assert.equal(b.list().remote.length, 1);
|
|
543
|
+
live.delete(511);
|
|
544
|
+
assert.deepEqual([b.list().remote.length, b.list().pool.length], [0, 0]);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("an unreachable pool is one line; none once cleared", async () => {
|
|
548
|
+
const { handle } = world();
|
|
549
|
+
const a = handle(521);
|
|
550
|
+
a.notePool("B", { reason: "capacity probe failed" });
|
|
551
|
+
const down = (await show(a)).lines.filter((m) => m.startsWith("pool:"));
|
|
552
|
+
assert.deepEqual(down, ["pool: unreachable (batch B: capacity probe failed)"]);
|
|
553
|
+
a.notePool("B", null);
|
|
554
|
+
assert.equal((await show(a)).lines.filter((m) => m.startsWith("pool:")).length, 0);
|
|
555
|
+
assert.deepEqual(remoteLines({}), []);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("orchestrate publishes a remote lane while it runs, drops it after, and never acquires a slot", async () => {
|
|
559
|
+
const { handle } = world();
|
|
560
|
+
const reg = handle(531);
|
|
561
|
+
let seen = null, acquired = 0;
|
|
562
|
+
const spy = { ...reg, tryAcquire: (...x) => { acquired++; return reg.tryAcquire(...x); } };
|
|
563
|
+
await orchestrate({ issues: [7], parallel: 1, pollMs: 5, trusted: () => true, readBody: async () => "", sched: { registry: spy, batch: "B", repo: "o/r" },
|
|
564
|
+
remote: { max: 1, isRemote: () => true, capacity: () => ({ free: 1, online: 1 }) },
|
|
565
|
+
runOne: async (n, lane, ctx) => { ctx.onStart?.({ handle: { id: "fdrsbx-B-7", agent: "legion" }, remote: true }); seen = reg.list(); return { terminalReason: "merged" }; } });
|
|
566
|
+
assert.equal(acquired, 0);
|
|
567
|
+
assert.deepEqual(seen.remote.map((r) => [r.issue, r.sandbox, r.agent]), [[7, "fdrsbx-B-7", "legion"]]);
|
|
568
|
+
assert.equal(seen.slots.length, 0);
|
|
569
|
+
assert.equal(reg.list().remote.length, 0);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
@@ -10,10 +10,14 @@ import { fileURLToPath } from "node:url";
|
|
|
10
10
|
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import { batchRoot, listBatches, openBatch } from "./batch.mjs";
|
|
12
12
|
import { staticCheck, formatStatic, realGh } from "./selftest-static.mjs";
|
|
13
|
+
import { pollUntil } from "./retry.mjs";
|
|
13
14
|
import { postMarker } from "./selftest-gate.mjs";
|
|
14
15
|
import { reportNightly } from "./selftest-nightly.mjs";
|
|
15
16
|
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
16
17
|
|
|
18
|
+
const BATCH_WAIT_TRIES = 60, BATCH_WAIT_MS = 1000;
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 45 * 60 * 1000, DEFAULT_POLL_MS = 5000, GH_MAX_BUFFER = 64 * 1024 * 1024;
|
|
20
|
+
|
|
17
21
|
export class StepError extends Error {
|
|
18
22
|
constructor(step, message) { super(message); this.step = step; }
|
|
19
23
|
}
|
|
@@ -33,20 +37,31 @@ export const FIXTURES = {
|
|
|
33
37
|
},
|
|
34
38
|
};
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The fixture with every path and title made unique to this run (#4952): a merged run leaves its files on the sandbox
|
|
42
|
+
* default branch, and the next run's "the test fails on the current code" premise must still hold.
|
|
43
|
+
*/
|
|
44
|
+
export function uniqueFixture(fx, lang, runId) {
|
|
45
|
+
const re = lang === "python" ? /`((?:test_)?[ab]\.py)`/g : /`src\/([ab](?:\.test)?\.js)`/g;
|
|
46
|
+
const dir = (m, f) => (lang === "python" ? `\`${runId}/${f}\`` : `\`src/${runId}/${f}\``);
|
|
47
|
+
const one = (i) => ({ title: `${i.title} (${runId})`, body: i.body.replace(re, dir) });
|
|
48
|
+
return { files: fx.files, a: one(fx.a), b: one(fx.b) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a batch plan is the one this harness started: the sandbox repo and every issue it passed (#4952). */
|
|
52
|
+
export const isOwnBatch = (plan, repo, issues) =>
|
|
53
|
+
String(plan?.repo ?? "").toLowerCase() === repo.toLowerCase() && issues.every((n) => (plan?.argv ?? []).map(String).includes(String(n)));
|
|
54
|
+
|
|
36
55
|
/** The issue body with its FORGE:STATE annotation removed. */
|
|
37
56
|
export const stripState = (body) => String(body ?? "").replace(/<!--\s*FORGE:STATE[\s\S]*?-->/g, "").trim();
|
|
38
57
|
|
|
39
58
|
const num = (out) => { const m = /\/issues\/(\d+)/.exec(String(out)) ?? /^(\d+)$/m.exec(String(out)); if (!m) throw new Error(`no issue number in: ${String(out).slice(0, 120)}`); return Number(m[1]); };
|
|
40
59
|
|
|
41
60
|
async function waitFor(ctx, step, what, cond, onTick) {
|
|
42
|
-
const { io, timeoutMs } = ctx
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (v) return v;
|
|
47
|
-
if (io.now() - t0 > timeoutMs) fail(step, `timed out waiting for ${what}`);
|
|
48
|
-
await io.sleep(ctx.pollMs);
|
|
49
|
-
}
|
|
61
|
+
const { io, timeoutMs, pollMs } = ctx;
|
|
62
|
+
const v = await pollUntil(async () => { if (onTick) await onTick(); return cond(); }, { timeoutMs, pollMs, sleep: io.sleep, now: io.now });
|
|
63
|
+
if (!v) fail(step, `timed out waiting for ${what}`);
|
|
64
|
+
return v;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
/** Step "body": every issue body still holds its creation text (#4860). */
|
|
@@ -59,16 +74,16 @@ async function checkBodies(ctx, step = "body") {
|
|
|
59
74
|
|
|
60
75
|
export const STEPS = [
|
|
61
76
|
{ name: "create", run: async (ctx) => {
|
|
62
|
-
const { io, repo, fx
|
|
77
|
+
const { io, repo } = ctx, fx = ctx.fx = uniqueFixture(ctx.fx, ctx.lang, ctx.runId);
|
|
63
78
|
for (const [path, content] of Object.entries(fx.files)) {
|
|
64
79
|
await io.gh(["api", "-X", "PUT", `repos/${repo}/contents/${path}`, "-f", `message=selftest fixture ${path}`, "-f", `content=${Buffer.from(content).toString("base64")}`]).catch(() => { /* already present */ });
|
|
65
80
|
}
|
|
66
|
-
|
|
81
|
+
// Tracked the moment it exists: if B's create fails, A is still cleaned up.
|
|
82
|
+
const mk = async (t, body) => { const n = num(await io.gh(["issue", "create", "-R", repo, "--title", t, "--body", body])); ctx.created.push(n); return n; };
|
|
67
83
|
const a = await mk(fx.a.title, fx.a.body);
|
|
68
84
|
const b = await mk(fx.b.title, `${fx.b.body}\n\n**Depends on**: #${a}`);
|
|
69
85
|
ctx.issues = { A: a, B: b };
|
|
70
86
|
ctx.bodies = { [a]: fx.a.body, [b]: fx.b.body };
|
|
71
|
-
ctx.created.push(a, b);
|
|
72
87
|
} },
|
|
73
88
|
{ name: "add", run: async (ctx) => {
|
|
74
89
|
const { io, repo, issues } = ctx;
|
|
@@ -84,12 +99,13 @@ export const STEPS = [
|
|
|
84
99
|
const { io, issues } = ctx;
|
|
85
100
|
const pid = async () => (await io.orchestrate.state(ctx.run.id))?.running?.[issues.A]?.pid ?? null;
|
|
86
101
|
const before = await waitFor(ctx, "resume", `a live lane for #${issues.A}`, pid);
|
|
102
|
+
// The batch runs inside schedd: killing the client stops nothing, so the batch itself is cancelled (#4952).
|
|
87
103
|
await ctx.run.kill();
|
|
104
|
+
await io.orchestrate.stop(ctx.run.id);
|
|
105
|
+
await waitFor(ctx, "resume", `the lane of #${issues.A} (pid ${before}) to stop after the cancel`, async () => ((await pid()) ? null : { stopped: true }));
|
|
88
106
|
ctx.run = await io.orchestrate.resume(ctx.run.id);
|
|
89
|
-
await io.
|
|
90
|
-
|
|
91
|
-
const done = st?.results?.[issues.A];
|
|
92
|
-
if (!done && after !== before) fail("resume", `#${issues.A} was launched again after --resume (pid ${before} -> ${after}), not re-adopted`);
|
|
107
|
+
const st = await waitFor(ctx, "resume", `the batch to pick #${issues.A} up again after --resume`, async () => { const s = await io.orchestrate.state(ctx.run.id); return s?.results?.[issues.A] || s?.running?.[issues.A]?.pid ? s : null; });
|
|
108
|
+
if (!st.results?.[issues.A] && st.running?.[issues.A]?.pid === before) fail("resume", `#${issues.A} still has its pre-cancel lane (pid ${before}) after --resume: the batch was never stopped`);
|
|
93
109
|
await checkBodies(ctx, "body");
|
|
94
110
|
} },
|
|
95
111
|
{ name: "merge", run: async (ctx) => {
|
|
@@ -127,26 +143,31 @@ export const STEPS = [
|
|
|
127
143
|
* Run the steps in order; the first failure stops them, cleanup always runs.
|
|
128
144
|
* @returns {{ok:boolean, step:string|null, message:string|null, notes:string[], cleanupErrors:string[]}}
|
|
129
145
|
*/
|
|
130
|
-
export async function runSelftestLive({ repo, lang = "node", io, timeoutMs =
|
|
146
|
+
export async function runSelftestLive({ repo, lang = "node", io, timeoutMs = DEFAULT_TIMEOUT_MS, pollMs = DEFAULT_POLL_MS }) {
|
|
131
147
|
const fx = FIXTURES[lang];
|
|
132
148
|
if (!fx) return { ok: false, step: "setup", message: `unknown fixture language ${lang} (node|python)`, notes: [], cleanupErrors: [] };
|
|
133
|
-
const ctx = { io, repo, fx, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
|
|
149
|
+
const ctx = { io, repo, fx, lang, runId: `r${io.now?.().toString(36) ?? "0"}`, timeoutMs, pollMs, issues: {}, bodies: {}, created: [], notes: [], run: null };
|
|
134
150
|
let out = { ok: true, step: null, message: null };
|
|
135
151
|
for (const s of STEPS) {
|
|
136
152
|
try { await s.run(ctx); } catch (e) { out = { ok: false, step: e.step ?? s.name, message: String(e.message ?? e) }; break; }
|
|
137
153
|
}
|
|
138
154
|
const cleanupErrors = [];
|
|
139
155
|
const tryC = async (what, f) => { try { await f(); } catch (e) { cleanupErrors.push(`${what}: ${String(e.message ?? e).slice(0, 120)}`); } };
|
|
140
|
-
if (ctx.run)
|
|
156
|
+
if (ctx.run) {
|
|
157
|
+
await tryC("kill orchestrate client", () => ctx.run.kill());
|
|
158
|
+
await tryC("stop batch", () => io.orchestrate.stop(ctx.run.id));
|
|
159
|
+
}
|
|
141
160
|
for (const n of ctx.created) {
|
|
142
161
|
await tryC(`close #${n}`, () => io.gh(["issue", "close", String(n), "-R", repo]));
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
// The prefix search over-matches (issue-5 lists issue-50): keep only this issue's own branches, then find PRs by head.
|
|
163
|
+
const own = new RegExp(`^refs/heads/forge/issue-${n}(?![0-9])`);
|
|
164
|
+
const lines = async (args) => String(await io.gh(args)).split("\n").filter(Boolean);
|
|
165
|
+
await tryC(`clean up branches of #${n}`, async () => {
|
|
166
|
+
const refs = (await lines(["api", `repos/${repo}/git/matching-refs/heads/forge/issue-${n}`, "--jq", ".[].ref"])).filter((r) => own.test(r));
|
|
167
|
+
for (const ref of refs) {
|
|
168
|
+
for (const p of await lines(["pr", "list", "-R", repo, "--head", ref.replace(/^refs\/heads\//, ""), "--state", "open", "--json", "number", "--jq", ".[].number"])) await io.gh(["pr", "close", p, "-R", repo]);
|
|
169
|
+
await io.gh(["api", "-X", "DELETE", `repos/${repo}/git/${ref}`]);
|
|
170
|
+
}
|
|
150
171
|
});
|
|
151
172
|
}
|
|
152
173
|
return { ...out, notes: ctx.notes, cleanupErrors };
|
|
@@ -159,28 +180,32 @@ export function realIo(env = process.env, { pool = false } = {}) {
|
|
|
159
180
|
let self;
|
|
160
181
|
const root = batchRoot(env);
|
|
161
182
|
const detached = (args) => { const p = spawn(process.execPath, [CLI, ...args], { detached: true, windowsHide: true, stdio: "ignore", env }); p.unref(); return { kill: async () => { try { process.kill(-p.pid, "SIGTERM"); } catch { /* gone */ } } }; };
|
|
162
|
-
|
|
183
|
+
// The batch of THIS start: new on disk, for the sandbox repo, and naming the issue we passed. A concurrent real batch is never adopted (#4952).
|
|
184
|
+
const ownBatch = (id, repo, issues) => { try { return isOwnBatch(openBatch(root, id).readPlan(), repo, issues); } catch { return false; } };
|
|
185
|
+
const waitBatch = async (before, repo, issues) => { const id = await pollUntil(() => listBatches(root).find((b) => !before.has(b) && ownBatch(b, repo, issues)), { timeoutMs: BATCH_WAIT_TRIES * BATCH_WAIT_MS, pollMs: BATCH_WAIT_MS }); if (id) return id; throw new Error(`orchestrate did not create a batch for ${repo} ${issues.join(",")} within ${BATCH_WAIT_TRIES * BATCH_WAIT_MS / 1000}s`); };
|
|
163
186
|
return (self = {
|
|
164
187
|
now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
165
|
-
gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer:
|
|
188
|
+
gh: async (args) => { const r = spawnSync("gh", args, { encoding: "utf8", maxBuffer: GH_MAX_BUFFER }); if (r.status !== 0) throw new Error(r.stderr.trim() || `gh exited ${r.status}`); return r.stdout; },
|
|
166
189
|
cli: async (args) => { const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env }); return { code: r.status ?? 1, stdout: r.stdout, stderr: r.stderr }; },
|
|
167
190
|
cliVersion: async () => JSON.parse(readFileSync(join(dirname(CLI), "..", "package.json"), "utf8")).version,
|
|
168
191
|
orchestrate: {
|
|
169
192
|
start: async (repo, issues, parallel) => {
|
|
170
193
|
const before = new Set(listBatches(root)), h = detached(["orchestrate", ...issues.map(String), "--parallel", String(parallel), "--repo", repo]);
|
|
171
|
-
return { id: await waitBatch(before), kill: h.kill };
|
|
194
|
+
return { id: await waitBatch(before, repo, issues), kill: h.kill };
|
|
172
195
|
},
|
|
196
|
+
// The batch lives in schedd, not in the client: `--cancel` is what stops it (its lanes tear down, the rest is held for --resume).
|
|
197
|
+
stop: async (id) => { const r = await self.cli(["orchestrate", "--cancel", id]); if (r.code !== 0) throw new Error(`orchestrate --cancel ${id} exited ${r.code}: ${String(r.stderr || r.stdout).slice(0, 160)}`); },
|
|
173
198
|
resume: async (id) => ({ id, ...detached(["orchestrate", "--resume", id]) }),
|
|
174
199
|
state: async (id) => openBatch(root, id).store.load(),
|
|
175
200
|
},
|
|
176
201
|
// Step 6: one lane through the pool. The issue's FORGE:STATE records the placement and the lane's FORGE:TELEMETRY rows
|
|
177
202
|
// carry the engine's forge_version (the sandbox's, not the CLI's: the #4818 stale-image defect).
|
|
178
203
|
remote: pool ? { run: async ({ repo, issue, timeoutMs, pollMs }) => {
|
|
179
|
-
const run = await self.orchestrate.start(repo, [issue], 1)
|
|
204
|
+
const run = await self.orchestrate.start(repo, [issue], 1);
|
|
180
205
|
let row = null;
|
|
181
206
|
try {
|
|
182
|
-
|
|
183
|
-
} finally { await run.kill(); }
|
|
207
|
+
row = await pollUntil(async () => (await self.orchestrate.state(run.id))?.results?.[issue] ?? null, { timeoutMs, pollMs, sleep: self.sleep, now: self.now });
|
|
208
|
+
} finally { await run.kill(); await self.orchestrate.stop(run.id).catch(() => {}); }
|
|
184
209
|
if (!row) throw new Error(`#${issue} produced no result within ${Math.round(timeoutMs / 1000)}s`);
|
|
185
210
|
const v = JSON.parse(await self.gh(["issue", "view", String(issue), "-R", repo, "--json", "body,comments"]));
|
|
186
211
|
const text = `${v.body}\n${v.comments.map((c) => c.body).join("\n")}`;
|
|
@@ -5,11 +5,11 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import { writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
-
import { runSelftestLive, stripState, selftestFromCli, STEPS } from "./selftest-live.mjs";
|
|
8
|
+
import { runSelftestLive, stripState, selftestFromCli, STEPS, FIXTURES, uniqueFixture, isOwnBatch } from "./selftest-live.mjs";
|
|
9
9
|
|
|
10
10
|
/** A fake sandbox world. `d` toggles a defect: add4859 (--add skips), wipe4860 (body wiped), double (resume relaunches), orderBA, noRemoteAuth. */
|
|
11
11
|
function world(d = {}) {
|
|
12
|
-
const w = { issues: {}, closed: [], calls: [], killed: 0, pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
|
|
12
|
+
const w = { issues: {}, closed: [], calls: [], killed: 0, stops: [], pid: 100, results: {}, order: [], deps: {}, running: {}, clock: 0, pool: !!d.pool };
|
|
13
13
|
const gh = async (a) => {
|
|
14
14
|
w.calls.push(a.join(" "));
|
|
15
15
|
if (a[0] === "api" && a[1] === "-X" && a[2] === "PUT") return "{}";
|
|
@@ -36,7 +36,9 @@ function world(d = {}) {
|
|
|
36
36
|
},
|
|
37
37
|
orchestrate: {
|
|
38
38
|
start: async (repo, issues) => { w.order.push(...issues); w.running[issues[0]] = { pid: ++w.pid }; w.a = issues[0]; return { id: "b1", kill: async () => { w.killed++; } }; },
|
|
39
|
-
|
|
39
|
+
// stop = `orchestrate --cancel`: the lane is torn down (d.nostop: the defect where only the client dies and the lane lives on)
|
|
40
|
+
stop: async (id) => { w.stops.push(id); if (!d.nostop) delete w.running[w.a]; },
|
|
41
|
+
resume: async (id) => { w.resumedAt = w.tick ?? 0; w.running[w.a] = { pid: d.nostop ? w.running[w.a]?.pid ?? ++w.pid : ++w.pid }; return { id, kill: async () => { w.killed++; } }; },
|
|
40
42
|
state: async () => ({ order: w.order, deps: w.deps, running: w.running, results: w.results }),
|
|
41
43
|
},
|
|
42
44
|
remote: w.pool ? { run: async ({ issue }) => { w.remoteIssue = issue; return { where: d.local ? "local" : "remote", authenticated: !d.noRemoteAuth, engineVersion: d.oldEngine ? "1.37.0" : "1.39.0" }; } } : null,
|
|
@@ -73,9 +75,14 @@ describe("selftest --live steps (#4865)", () => {
|
|
|
73
75
|
const { r } = await run({ wipe4860: true });
|
|
74
76
|
assert.equal(r.ok, false); assert.equal(r.step, "body"); assert.match(r.message, /lost its body text/);
|
|
75
77
|
});
|
|
76
|
-
it("
|
|
77
|
-
const { r } = await run({
|
|
78
|
-
assert.equal(r.
|
|
78
|
+
it("resume really stops the batch (not just the client) and cleanup stops it again (#4952)", async () => {
|
|
79
|
+
const { r, w } = await run({});
|
|
80
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
81
|
+
assert.deepEqual(w.stops, ["b1", "b1"], "once in the resume step, once in cleanup");
|
|
82
|
+
});
|
|
83
|
+
it("a stop that leaves the lane running fails the resume step instead of passing vacuously (#4952)", async () => {
|
|
84
|
+
const { r } = await run({ nostop: true }, { timeoutMs: 20000 });
|
|
85
|
+
assert.equal(r.step, "resume"); assert.match(r.message, /to stop after the cancel/);
|
|
79
86
|
});
|
|
80
87
|
it("B merging before A fails the merge step", async () => {
|
|
81
88
|
const { r } = await run({ orderBA: true });
|
|
@@ -89,6 +96,41 @@ describe("selftest --live steps (#4865)", () => {
|
|
|
89
96
|
const ok = await run({ pool: true });
|
|
90
97
|
assert.equal(ok.w.remoteIssue, 12); assert.deepEqual(ok.w.closed, ["10", "11", "12"]);
|
|
91
98
|
});
|
|
99
|
+
it("fixtures are run-unique, so a merged run leaves nothing the next run trips on (#4952)", async () => {
|
|
100
|
+
const x = uniqueFixture(FIXTURES.node, "node", "r1"), y = uniqueFixture(FIXTURES.python, "python", "r1");
|
|
101
|
+
assert.match(x.a.body, /`src\/r1\/a\.js`.*`src\/r1\/a\.test\.js`/); assert.match(x.a.title, /\(r1\)/);
|
|
102
|
+
assert.match(y.b.body, /`r1\/b\.py`.*`r1\/test_b\.py`/);
|
|
103
|
+
const { w } = await run({});
|
|
104
|
+
assert.match(w.issues[10].body, /src\/r[0-9a-z]+\/a\.js/);
|
|
105
|
+
});
|
|
106
|
+
it("the created issue is tracked at once: A is closed when B's create fails (#4952)", async () => {
|
|
107
|
+
const { w, io } = world({}), gh = io.gh; let creates = 0;
|
|
108
|
+
io.gh = async (a) => { if (a[0] === "issue" && a[1] === "create" && ++creates === 2) throw new Error("rate limited"); return gh(a); };
|
|
109
|
+
const r = await runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000 });
|
|
110
|
+
assert.equal(r.step, "create"); assert.deepEqual(w.closed, ["10"]);
|
|
111
|
+
});
|
|
112
|
+
it("cleanup matches branches exactly and finds PRs by the head branch it created (#4952)", async () => {
|
|
113
|
+
const { w, io } = world({ add4859: true }), gh = io.gh, log = [];
|
|
114
|
+
io.gh = async (a) => {
|
|
115
|
+
if (a[0] === "api" && a[1].includes("matching-refs/heads/forge/issue-10")) return "refs/heads/forge/issue-10\nrefs/heads/forge/issue-100\nrefs/heads/forge/issue-10-x\n";
|
|
116
|
+
if (a[0] === "api" && a[2] === "DELETE") { log.push(`del ${a[3].split("git/")[1]}`); return ""; }
|
|
117
|
+
if (a[0] === "api") return "";
|
|
118
|
+
if (a[0] === "pr" && a[1] === "list") { log.push(`list ${a[a.indexOf("--head") + 1]}`); return a.includes("forge/issue-10") ? "7\n" : ""; }
|
|
119
|
+
if (a[0] === "pr" && a[1] === "close") { log.push(`close ${a[2]}`); return ""; }
|
|
120
|
+
return gh(a);
|
|
121
|
+
};
|
|
122
|
+
const r = await runSelftestLive({ repo: "o/sandbox", io, pollMs: 1, timeoutMs: 60000 });
|
|
123
|
+
assert.deepEqual(r.cleanupErrors, []);
|
|
124
|
+
assert.deepEqual(log, ["list forge/issue-10", "close 7", "del refs/heads/forge/issue-10", "list forge/issue-10-x", "del refs/heads/forge/issue-10-x"]);
|
|
125
|
+
assert.ok(!w.calls.some((c) => /search/.test(c)), "no text search for PRs");
|
|
126
|
+
});
|
|
127
|
+
it("own batch is picked by repo and issues: a foreign concurrent batch is never adopted (#4952)", () => {
|
|
128
|
+
const mine = { repo: "o/Sandbox", argv: ["orchestrate", "10", "11", "--repo", "o/Sandbox"] };
|
|
129
|
+
assert.ok(isOwnBatch(mine, "o/sandbox", [10, 11]));
|
|
130
|
+
assert.ok(!isOwnBatch({ repo: "o/real", argv: ["orchestrate", "10", "11"] }, "o/sandbox", [10, 11]));
|
|
131
|
+
assert.ok(!isOwnBatch({ repo: "o/sandbox", argv: ["orchestrate", "7"] }, "o/sandbox", [10, 11]));
|
|
132
|
+
assert.ok(!isOwnBatch(null, "o/sandbox", [10]));
|
|
133
|
+
});
|
|
92
134
|
it("unknown fixture language fails at setup", async () => {
|
|
93
135
|
assert.equal((await runSelftestLive({ repo: "o/r", lang: "ruby", io: {} })).step, "setup");
|
|
94
136
|
});
|
|
@@ -9,9 +9,9 @@ import { spawn } from "node:child_process";
|
|
|
9
9
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { schedDir } from "./sched.mjs";
|
|
12
|
+
import { neutralize, findMarked } from "./prodwatch-triage.mjs";
|
|
12
13
|
|
|
13
14
|
export const NIGHTLY_MARKER = "<!-- FORGE:SELFTEST_NIGHTLY -->";
|
|
14
|
-
const neutralize = (s) => String(s ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
15
15
|
/** One last-run file per repo: two repos on one host never share (and so never skip) each other's schedule. */
|
|
16
16
|
const stateFile = (repo) => `selftest-nightly-${String(repo).replace(/[^\w.-]+/g, "_")}.json`;
|
|
17
17
|
|
|
@@ -49,8 +49,12 @@ export async function reportNightly({ repo, io, now = () => Date.now(), result }
|
|
|
49
49
|
const step = result?.step ?? "unknown";
|
|
50
50
|
const title = `selftest --live failed at step ${step} (nightly)`;
|
|
51
51
|
const body = `The nightly \`forgedock selftest --live\` failed at step **${neutralize(step)}**.\n\n${neutralize(String(result?.message ?? "").slice(0, 2000))}\n\nLast failure: ${new Date(now()).toISOString()}\n\n${NIGHTLY_MARKER}\n`;
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// #4952: the marker is public text, so only an issue authored by the actor running the harness is edited; an unreadable
|
|
53
|
+
// login trusts nobody (a new issue is filed instead).
|
|
54
|
+
let me = "";
|
|
55
|
+
try { me = String(await io.gh(["api", "user", "--jq", ".login"])).trim().toLowerCase(); } catch { /* untrusted */ }
|
|
56
|
+
const issues = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body,author"])));
|
|
57
|
+
const open = findMarked(issues, NIGHTLY_MARKER, (i) => me && String(i.author?.login ?? "").toLowerCase() === me);
|
|
54
58
|
if (open) {
|
|
55
59
|
await io.gh(["issue", "edit", String(open.number), "-R", repo, "--title", title, "--body", body]);
|
|
56
60
|
return { issue: open.number, action: "edit" };
|
|
@@ -8,7 +8,7 @@ import { nightlyConfig, nightlyDue, launchNightly, NIGHTLY_MARKER } from "./self
|
|
|
8
8
|
|
|
9
9
|
const setup = (yaml) => { const cwd = tmpDir("nt-"), stateDir = tmpDir("ns-"); if (yaml) writeFileSync(join(cwd, "forge.yaml"), yaml); return { cwd, stateDir }; };
|
|
10
10
|
const YAML = "selftest:\n sandbox_repo: a/b\n nightly_interval_hours: 24\n";
|
|
11
|
-
const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
|
|
11
|
+
const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[0] === "api") return "bot\n"; if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
|
|
12
12
|
|
|
13
13
|
test("nightlyConfig: unset, zero and other sections are off", () => {
|
|
14
14
|
assert.equal(nightlyConfig(""), null);
|
|
@@ -42,7 +42,7 @@ test("failure files one issue naming the step; the second failure edits it", asy
|
|
|
42
42
|
assert.match(create.join(" "), /step body/); assert.ok(create.join(" ").includes(NIGHTLY_MARKER));
|
|
43
43
|
assert.ok(!create[create.indexOf("--body") + 1].includes("<!-- x"));
|
|
44
44
|
t += 25 * 3600_000;
|
|
45
|
-
const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}
|
|
45
|
+
const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}`, author: { login: "Bot" } }]);
|
|
46
46
|
const r2 = await nightlyDue({ ...s, repo: "o/r", io: io2, now: () => t, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
47
47
|
assert.deepEqual([r2.action, r2.issue], ["edit", 77]);
|
|
48
48
|
assert.ok(!io2.calls.some((a) => a[1] === "create"));
|
|
@@ -82,3 +82,25 @@ test("launchNightly: detached, unref'd, and an async spawn error never escapes (
|
|
|
82
82
|
assert.doesNotThrow(() => child.emit("error", Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" })));
|
|
83
83
|
assert.equal(launchNightly({ cli: "c", repo: "o/r", spawnFn: () => { throw new Error("EMFILE"); } }), null);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
test("the dedupe never edits a marker-bearing issue authored by someone else (#4952)", async () => {
|
|
87
|
+
const s = setup(YAML), io = fakeIo([{ number: 5, body: `evil\n${NIGHTLY_MARKER}`, author: { login: "mallory" } }, { number: 6, body: `no marker`, author: { login: "bot" } }]);
|
|
88
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
89
|
+
assert.deepEqual([r.action, r.issue], ["create", 77]);
|
|
90
|
+
assert.ok(!io.calls.some((a) => a[1] === "edit"));
|
|
91
|
+
assert.ok(io.calls.find((a) => a[1] === "list").join(" ").includes("number,body,author"));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an unreadable login trusts nobody: a new issue is filed", async () => {
|
|
95
|
+
const s = setup(YAML), io = fakeIo([{ number: 5, body: NIGHTLY_MARKER, author: { login: "bot" } }]), gh = io.gh;
|
|
96
|
+
io.gh = async (a) => { if (a[0] === "api") throw new Error("no token user"); return gh(a); };
|
|
97
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
98
|
+
assert.equal(r.action, "create");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a marker quoted mid-body by the trusted author is not a dedupe hit (shared trailer rule)", async () => {
|
|
102
|
+
const s = setup(YAML), io = fakeIo([{ number: 5, body: `quote: ${NIGHTLY_MARKER}\nmore text`, author: { login: "bot" } }]);
|
|
103
|
+
const r = await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
|
|
104
|
+
assert.equal(r.action, "create");
|
|
105
|
+
assert.ok(!io.calls.some((a) => a[1] === "edit"));
|
|
106
|
+
});
|
|
@@ -18,15 +18,20 @@ export function listSources(dir) {
|
|
|
18
18
|
return out.sort();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The `gh issue|pr view|list --json <fields>` uses of `text`. A literal field list is `{cmd, fields, line}`; a list the
|
|
23
|
+
* scan cannot read (a variable or template, e.g. `"--json", fields`) is `{cmd, fields: [], unchecked: true, line}`.
|
|
24
|
+
* @returns {{cmd:string, fields:string[], line:number, unchecked?:true}[]} cmd e.g. "issue view"
|
|
25
|
+
*/
|
|
22
26
|
export function extractJsonFields(text) {
|
|
23
|
-
const out = [];
|
|
24
|
-
const re = /\[([^[\]]*?)["'`]--json["'`]\s*,\s*["'`]([A-Za-z0-9_,]+)["'`]/g;
|
|
27
|
+
const out = [], re = /["'`]--json["'`]\s*,\s*/g;
|
|
25
28
|
for (let m; (m = re.exec(text));) {
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
29
|
-
|
|
29
|
+
const before = text.slice(Math.max(0, m.index - 300), m.index), seg = before.slice(before.lastIndexOf(";") + 1);
|
|
30
|
+
const cmds = [...seg.matchAll(/["'`](issue|pr)["'`]\s*,\s*["'`](view|list)["'`]/g)];
|
|
31
|
+
if (!cmds.length) continue;
|
|
32
|
+
const c = cmds.at(-1), lit = /^["'`]([A-Za-z0-9_,]+)["'`]\s*[,\])]/.exec(text.slice(m.index + m[0].length));
|
|
33
|
+
const row = { cmd: `${c[1]} ${c[2]}`, fields: lit ? lit[1].split(",").filter(Boolean) : [], line: text.slice(0, m.index).split("\n").length };
|
|
34
|
+
out.push(lit ? row : { ...row, unchecked: true });
|
|
30
35
|
}
|
|
31
36
|
return out;
|
|
32
37
|
}
|
|
@@ -47,14 +52,15 @@ export function parseHelpFields(help) {
|
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
54
|
* @param {{root:string, io:{gh:(args:string[])=>string, read?:(p:string)=>string, list?:(d:string)=>string[]}}} a
|
|
50
|
-
* @returns {{ok:boolean, checked:number, missing:{file:string,line:number,cmd:string,field:string}[], errors:string[]}}
|
|
55
|
+
* @returns {{ok:boolean, checked:number, unchecked:{file:string,line:number,cmd:string}[], missing:{file:string,line:number,cmd:string,field:string}[], errors:string[]}}
|
|
51
56
|
*/
|
|
52
57
|
export function staticCheck({ root, io }) {
|
|
53
58
|
const read = io.read ?? ((p) => readFileSync(p, "utf8")), list = io.list ?? listSources;
|
|
54
|
-
const known = new Map(), errors = [], missing = [];
|
|
59
|
+
const known = new Map(), errors = [], missing = [], unchecked = [];
|
|
55
60
|
let checked = 0;
|
|
56
61
|
for (const file of list(root)) {
|
|
57
62
|
for (const u of extractJsonFields(read(file))) {
|
|
63
|
+
if (u.unchecked) { unchecked.push({ file, line: u.line, cmd: u.cmd }); continue; } // reported, never silently skipped (#4952)
|
|
58
64
|
if (!known.has(u.cmd)) {
|
|
59
65
|
let fields = [];
|
|
60
66
|
try { fields = parseHelpFields(io.gh([...u.cmd.split(" "), "--help"])); } catch (e) { errors.push(`gh ${u.cmd} --help failed: ${e.message}`); }
|
|
@@ -67,7 +73,7 @@ export function staticCheck({ root, io }) {
|
|
|
67
73
|
for (const field of u.fields) if (!set.has(field)) missing.push({ file, line: u.line, cmd: u.cmd, field });
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
|
-
return { ok: !missing.length && !errors.length, checked, missing, errors };
|
|
76
|
+
return { ok: !missing.length && !errors.length, checked, unchecked, missing, errors };
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export const realGh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
@@ -76,6 +82,7 @@ export function formatStatic(r) {
|
|
|
76
82
|
return [
|
|
77
83
|
...r.missing.map((m) => `${m.file}:${m.line}: gh ${m.cmd} --json field "${m.field}" is not offered by this gh`),
|
|
78
84
|
...r.errors,
|
|
79
|
-
|
|
85
|
+
...(r.unchecked ?? []).map((u) => `${u.file}:${u.line}: gh ${u.cmd} --json field list is not a string literal: unchecked`),
|
|
86
|
+
`static check: ${r.ok ? "ok" : "FAILED"} (${r.checked} field lists checked, ${(r.unchecked ?? []).length} unchecked)`,
|
|
80
87
|
].join("\n");
|
|
81
88
|
}
|
|
@@ -29,4 +29,12 @@ describe("selftest static check (#4865)", () => {
|
|
|
29
29
|
const r = staticCheck({ root: "/bin", io: { gh: () => { throw new Error("no gh"); }, list: () => ["/a.mjs"], read: () => 'x(["pr", "view", "--json", "title"])' } });
|
|
30
30
|
assert.equal(r.ok, false);
|
|
31
31
|
});
|
|
32
|
+
it("reports a non-literal --json list as unchecked, not as checked (#4952)", () => {
|
|
33
|
+
const t = 'x();\ngh(["issue", "view", String(n), ...(repo ? ["-R", repo] : []), "--json", fields]);\ngh(["pr", "list", "--json", `a,${f}`]);';
|
|
34
|
+
assert.deepEqual(extractJsonFields(t), [{ cmd: "issue view", fields: [], line: 2, unchecked: true }, { cmd: "pr list", fields: [], line: 3, unchecked: true }]);
|
|
35
|
+
const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => t } });
|
|
36
|
+
assert.equal(r.ok, true); assert.equal(r.checked, 0); assert.equal(r.unchecked.length, 2);
|
|
37
|
+
assert.match(formatStatic(r), /a\.mjs:2: gh issue view --json field list is not a string literal: unchecked/);
|
|
38
|
+
assert.match(formatStatic(r), /0 field lists checked, 2 unchecked/);
|
|
39
|
+
});
|
|
32
40
|
});
|