forgedock 1.28.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +77 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +356 -0
- package/bin/lean/context.test.mjs +307 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +178 -37
- package/bin/lean/lean.test.mjs +441 -7
- package/bin/lean/orchestrate.mjs +112 -5
- package/bin/lean/prefilter.mjs +81 -16
- package/bin/lean/prefilter.test.mjs +91 -2
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +419 -0
- package/bin/lean/render.mjs +38 -5
- package/bin/lean/testmap.mjs +101 -0
- package/bin/lean/testmap.test.mjs +73 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +54 -7
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +317 -0
- package/scripts/test-map.test.sh +353 -0
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +13 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4267: in-batch recovery of mechanical needs-human (orchestrate, run-issue/orchestrate CLI, /orchestrate spec).
|
|
4
|
+
// The runCore side (--recover label/decision/prompt, issueEdited) is in lean.test.mjs next to its harness.
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { orchestrate, formatOutcomeLists, OUTCOME_LINE_MAX } from "./orchestrate.mjs";
|
|
10
|
+
import { RECOVER_HOW } from "./lean.mjs";
|
|
11
|
+
import { recoveryEvidence } from "./render.mjs";
|
|
12
|
+
import { RECOVERABLE_CODES } from "../engine/outcome-codes.mjs";
|
|
13
|
+
import { runFromCli, orchestrateFromCli } from "../engine-cli.mjs";
|
|
14
|
+
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
15
|
+
process.env.FORGEDOCK_SCHED_DIR = tmpDir("recovery-sched-");
|
|
16
|
+
|
|
17
|
+
describe("in-batch recovery of mechanical needs-human (#4267)", () => {
|
|
18
|
+
// runOne: `script[n]` is a list of results, one per launch of issue n (the last repeats); ctx is recorded.
|
|
19
|
+
const batch = (script, bodies = {}, extra = {}) => {
|
|
20
|
+
const calls = [], decisions = [];
|
|
21
|
+
const p = orchestrate({ issues: Object.keys(script).map(Number), parallel: 3, recovery: { maxAttempts: extra.maxAttempts ?? 1 },
|
|
22
|
+
readBody: async (n) => bodies[n] ?? "",
|
|
23
|
+
runOne: async (n, _lane, ctx = {}) => {
|
|
24
|
+
calls.push({ n, recover: ctx.recover ?? null });
|
|
25
|
+
await new Promise((r) => setImmediate(r));
|
|
26
|
+
const list = script[n] ?? [{ terminalReason: "merged" }];
|
|
27
|
+
const k = calls.filter((c) => c.n === n).length - 1;
|
|
28
|
+
return list[Math.min(k, list.length - 1)];
|
|
29
|
+
},
|
|
30
|
+
decide: (d) => { decisions.push(d); }, ...extra });
|
|
31
|
+
return p.then((rows) => ({ rows, calls, decisions }));
|
|
32
|
+
};
|
|
33
|
+
const NH = (code, detail = `ended ${code}`, more = {}) => ({ terminalReason: "needs-human", code, detail, ...more });
|
|
34
|
+
const byIssue = (rows) => Object.fromEntries(rows.map((r) => [r.issue, r]));
|
|
35
|
+
|
|
36
|
+
it("ci.red: one recovery lane on the PR; green re-queues the issue and its dependent in the same batch, no operator action", async () => {
|
|
37
|
+
const { rows, calls, decisions } = await batch({ 1: [NH("ci.red", "Required check(s) failed on PR #101: test"), { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "merged", pr: 102 }] }, { 2: "**Depends on**: #1" });
|
|
38
|
+
const r = byIssue(rows);
|
|
39
|
+
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].recovered, "ci.red");
|
|
40
|
+
assert.equal(r[2].outcome, "merged", "the dependent was not skipped");
|
|
41
|
+
assert.deepEqual(calls.filter((c) => c.n === 1).map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
42
|
+
assert.ok(calls.findIndex((c) => c.n === 2) > calls.findLastIndex((c) => c.n === 1), "the dependent ran after the recovered merge");
|
|
43
|
+
const d = decisions.find((x) => x.issue === 1);
|
|
44
|
+
assert.match(d.what, /recovery lane for ci\.red/); assert.match(d.why, /Required check/); assert.ok(d.alternative);
|
|
45
|
+
assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red)"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("a recovery that stays red is final with its evidence; dependents stay skipped; no attempt beyond max_attempts", async () => {
|
|
49
|
+
const { rows, calls } = await batch({ 1: [NH("ci.red"), NH("ci.red", "CI verification failed again", { recovery: true })], 2: [] }, { 2: "**Depends on**: #1" });
|
|
50
|
+
const r = byIssue(rows);
|
|
51
|
+
assert.equal(calls.filter((c) => c.n === 1).length, 2, "the first run plus exactly one recovery");
|
|
52
|
+
assert.equal(r[1].outcome, "needs-human");
|
|
53
|
+
assert.deepEqual(r[1].recovery, { code: "ci.red", attempts: 1, outcome: "needs-human", result_code: "ci.red", detail: "CI verification failed again" });
|
|
54
|
+
assert.equal(r[2].outcome, "skipped"); assert.ok(!calls.some((c) => c.n === 2));
|
|
55
|
+
const lines = formatOutcomeLists(rows);
|
|
56
|
+
assert.equal(lines.length, 1); assert.match(lines[0], /^unrecovered: #1 \(ci\.red: recovery ci\.red ended needs-human: CI verification failed again\)$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("human codes (product-decision, spend) are never recovered, still skip dependents and are the only escalations, asked once while other lanes keep running", async () => {
|
|
60
|
+
const { rows, calls, decisions } = await batch({
|
|
61
|
+
1: [NH("builder.blocked", "Builder is blocked (product-decision): pick a pricing model")],
|
|
62
|
+
2: [],
|
|
63
|
+
3: [NH("builder.blocked", "Builder is blocked (spend): needs a paid plan")],
|
|
64
|
+
4: [NH("conformance.omission", "R2 not covered")],
|
|
65
|
+
5: [{ terminalReason: "merged" }] }, { 2: "**Depends on**: #1" });
|
|
66
|
+
const r = byIssue(rows);
|
|
67
|
+
assert.equal(calls.filter((c) => c.n === 1).length, 1); assert.equal(calls.filter((c) => c.n === 3).length, 1);
|
|
68
|
+
assert.equal(calls.filter((c) => c.n === 4).length, 1, "terminal mechanical codes are not recovered either");
|
|
69
|
+
assert.ok(!decisions.some((d) => [1, 3].includes(d.issue)));
|
|
70
|
+
assert.equal(r[2].outcome, "skipped"); assert.equal(r[5].outcome, "merged");
|
|
71
|
+
const lines = formatOutcomeLists(rows);
|
|
72
|
+
assert.equal(lines.filter((l) => l.startsWith("escalations")).length, 1, "one escalation list");
|
|
73
|
+
const esc = lines.find((l) => l.startsWith("escalations"));
|
|
74
|
+
assert.match(esc, /#1 \(builder\.blocked\): Builder is blocked \(product-decision\)/);
|
|
75
|
+
assert.match(esc, /#3 \(builder\.blocked\): Builder is blocked \(spend\)/);
|
|
76
|
+
assert.doesNotMatch(esc, /#4/, "the needs-human list holds only human codes");
|
|
77
|
+
assert.ok(lines.some((l) => /^unrecovered: #4 \(conformance\.omission: R2 not covered\)$/.test(l)));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("base.conflict: a recovery base-sync with a clean result re-queues the issue; the report lists recovered issues separately", async () => {
|
|
81
|
+
const { rows, calls } = await batch({ 1: [NH("ci.red"), { terminalReason: "merged" }], 2: [NH("base.conflict", "PR #102 still conflicts with staging after 2 base-syncs"), { terminalReason: "merged" }] });
|
|
82
|
+
assert.deepEqual(calls.filter((c) => c.n === 2).map((c) => c.recover?.code ?? null), [null, "base.conflict"]);
|
|
83
|
+
assert.equal(byIssue(rows)[2].recovered, "base.conflict");
|
|
84
|
+
assert.deepEqual(formatOutcomeLists(rows), ["recovered: #1 (ci.red) #2 (base.conflict)"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("ci-pending recovers only when its PR conflicts (as base.conflict); a slow-CI ci-pending is left as is", async () => {
|
|
88
|
+
const seen = [];
|
|
89
|
+
const conflicting = async (n, pr) => { seen.push([n, pr]); return n === 1; };
|
|
90
|
+
const { rows, calls } = await batch({ 1: [{ terminalReason: "ci-pending", pr: 101 }, { terminalReason: "merged", pr: 101 }], 2: [{ terminalReason: "ci-pending", pr: 102 }] }, {}, { conflicting });
|
|
91
|
+
const r = byIssue(rows);
|
|
92
|
+
assert.equal(r[1].recovered, "base.conflict");
|
|
93
|
+
assert.equal(r[2].outcome, "ci-pending"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
|
|
94
|
+
assert.deepEqual(seen.sort(), [[1, 101], [2, 102]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("contract.invalid recovers only when the issue was edited mid-run", async () => {
|
|
98
|
+
const { rows, calls } = await batch({ 1: [NH("contract.invalid", "contract invalid", { issueEdited: true }), { terminalReason: "merged" }], 2: [NH("contract.invalid")] });
|
|
99
|
+
assert.equal(byIssue(rows)[1].recovered, "contract.invalid");
|
|
100
|
+
assert.equal(byIssue(rows)[2].outcome, "needs-human"); assert.equal(calls.filter((c) => c.n === 2).length, 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("relabelNeedsHuman (SPEC-7/8): only a recovery that ends final engine-error/deferred relabels; invalid/merged never; a requeued deferred waits", async () => {
|
|
104
|
+
const relabels = [];
|
|
105
|
+
const rl = { relabelNeedsHuman: async (x) => { relabels.push(x); }, pollMs: 5 };
|
|
106
|
+
// engine-error after the recovery lane's own relaunch: final -> exactly one relabel with the recovery evidence
|
|
107
|
+
const a = await batch({ 1: [NH("ci.red"), { terminalReason: "engine-error", detail: "boom" }] }, {}, rl);
|
|
108
|
+
assert.equal(a.rows[0].outcome, "engine-error"); assert.equal(relabels.length, 1);
|
|
109
|
+
assert.equal(relabels[0].issue, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `engine-error`/);
|
|
110
|
+
// invalid / merged / decomposed: never
|
|
111
|
+
relabels.length = 0;
|
|
112
|
+
await batch({ 1: [NH("ci.red"), { terminalReason: "invalid", detail: "not a bug" }], 2: [NH("ci.red"), { terminalReason: "merged" }],
|
|
113
|
+
3: [NH("ci.red"), { terminalReason: "decomposed", subissues: [] }] }, {}, rl);
|
|
114
|
+
assert.deepEqual(relabels, []);
|
|
115
|
+
// a deferred recovery that is requeued is not relabelled until it is final (capped)
|
|
116
|
+
const defer = { terminalReason: "deferred", code: "gh.unavailable", retryInSeconds: 0.01, detail: "gh down" };
|
|
117
|
+
const seen = [];
|
|
118
|
+
const d = await batch({ 1: [NH("ci.red"), defer] }, {}, { ...rl, maxDeferrals: 2, onUpdate: (r) => seen.push([r.outcome, relabels.length, String(r.detail)]) });
|
|
119
|
+
assert.equal(d.rows[0].outcome, "deferred");
|
|
120
|
+
const rq = seen.filter(([o, , det]) => o === "deferred" && /^requeued in/.test(det));
|
|
121
|
+
assert.ok(rq.length >= 1 && rq.every(([, n]) => n === 0), "a requeued deferral is not relabelled");
|
|
122
|
+
assert.equal(relabels.filter((x) => x.issue === 1).length, 1, "once, only when final");
|
|
123
|
+
// without a recovery pass nothing is relabelled
|
|
124
|
+
relabels.length = 0;
|
|
125
|
+
await batch({ 1: [{ terminalReason: "engine-error" }] }, {}, rl);
|
|
126
|
+
assert.deepEqual(relabels, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("without the recovery option the runner behaves as before: needs-human is final and dependents skip", async () => {
|
|
130
|
+
const calls = [];
|
|
131
|
+
const rows = await orchestrate({ issues: [1, 2], readBody: async (n) => (n === 2 ? "**Depends on**: #1" : ""),
|
|
132
|
+
runOne: async (n) => { calls.push(n); return NH("ci.red"); } });
|
|
133
|
+
assert.deepEqual(calls, [1]); assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("the attempt count survives a resume: a persisted recovery lane is launched with ctx.recover and is not retried again", async () => {
|
|
137
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } } };
|
|
138
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
139
|
+
const ctxs = [];
|
|
140
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 1 }, readBody: async () => "",
|
|
141
|
+
runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return NH("ci.red"); } });
|
|
142
|
+
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 1, final: true }]);
|
|
143
|
+
assert.equal(rows[0].outcome, "needs-human"); assert.equal(rows[0].recovery.code, "ci.red");
|
|
144
|
+
assert.deepEqual(state.recovering, {});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("other runner decisions are recorded too (engine-error relaunch, dependency requeue, sub-issues), and a throwing recorder never blocks the run", async () => {
|
|
148
|
+
const { decisions } = await batch({ 1: [{ terminalReason: "engine-error", detail: "boom" }, { terminalReason: "merged" }], 2: [{ terminalReason: "decomposed", subissues: [8] }] });
|
|
149
|
+
assert.ok(decisions.some((d) => d.issue === 1 && /relaunch/.test(d.what) && d.why === "boom" && d.alternative));
|
|
150
|
+
assert.ok(decisions.some((d) => d.issue === 2 && /#8/.test(d.what)));
|
|
151
|
+
const rows = await orchestrate({ issues: [1], recovery: { maxAttempts: 1 }, readBody: async () => "", decide: () => { throw new Error("gh down"); },
|
|
152
|
+
runOne: async (n, l, ctx) => (ctx.recover ? { terminalReason: "merged" } : NH("ci.red")) });
|
|
153
|
+
assert.equal(rows[0].recovered, "ci.red");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
it("run-issue --recover reaches runCore; a batch relaunches a ci.red lane with recover and reports it", async () => {
|
|
158
|
+
const got = [];
|
|
159
|
+
const l = console.log; console.log = () => {};
|
|
160
|
+
try {
|
|
161
|
+
await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "base.conflict", "--recover-attempt", "1"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
162
|
+
leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
|
|
163
|
+
} finally { console.log = l; }
|
|
164
|
+
assert.deepEqual(got, [{ code: "base.conflict", attempt: 1, final: true }]);
|
|
165
|
+
// The batch: a ci.red lane is relaunched in-process with recover, the decision is recorded and the report lists it.
|
|
166
|
+
const leanIo = {
|
|
167
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
168
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
169
|
+
};
|
|
170
|
+
const runs = [], decisions = [], logs = [];
|
|
171
|
+
console.log = (x) => logs.push(String(x));
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
|
|
175
|
+
io: { gh: async () => "o/r\n" }, leanIo, sched: null, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: (d) => decisions.push(d),
|
|
176
|
+
leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return o.recover ? { terminalReason: "merged", pr: 55 } : { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
|
|
177
|
+
} finally { console.log = l; }
|
|
178
|
+
assert.deepEqual(runs, [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
179
|
+
assert.equal(res.ok, true);
|
|
180
|
+
assert.ok(decisions.some((d) => d.issue === 5 && /recovery lane for ci\.red/.test(d.what)));
|
|
181
|
+
assert.ok(logs.includes("recovered: #5 (ci.red)"), logs.join("\n"));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("/orchestrate spec step 3 lists the recoverable codes, asks no confirmation on them, and escalates once at the end", async () => {
|
|
185
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
186
|
+
const step3 = spec.slice(spec.indexOf("## 3. Launch lanes"), spec.indexOf("## 4. Report"));
|
|
187
|
+
for (const c of ["`ci.red`", "`base.conflict`", "`ci-pending` on a conflicting", "`contract.invalid` caused by an issue edited mid-run"]) assert.ok(step3.includes(c), c);
|
|
188
|
+
assert.match(step3, /\*\*Do not ask for confirmation on them\.\*\*/);
|
|
189
|
+
assert.match(step3, /FORGE:DECISION/);
|
|
190
|
+
assert.match(step3, /one question list[\s\S]*once, at the end of the batch/);
|
|
191
|
+
assert.match(spec, /recovered: #A \(ci\.red\) #B \(base\.conflict\)/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("#4302: with maxAttempts 2 only the last attempt is final; a non-final attempt is not relabelled while its successor runs", async () => {
|
|
195
|
+
const relabels = [];
|
|
196
|
+
const conf = { maxAttempts: 2, conflicting: async () => true, relabelNeedsHuman: async (x) => { relabels.push(x); } };
|
|
197
|
+
const pend = { terminalReason: "ci-pending", pr: 9, detail: "CI still running" };
|
|
198
|
+
const seen = [];
|
|
199
|
+
const { rows, calls } = await batch({ 1: [NH("base.conflict"), pend, pend] }, {}, { ...conf, onUpdate: (r) => seen.push([r.outcome, relabels.length]) });
|
|
200
|
+
assert.deepEqual(calls.map((c) => c.recover), [null, { code: "base.conflict", attempt: 1, final: false }, { code: "base.conflict", attempt: 2, final: true }]);
|
|
201
|
+
assert.equal(rows[0].outcome, "ci-pending");
|
|
202
|
+
assert.ok(seen.filter(([o]) => o === "recovering").every(([, k]) => k === 0), "no relabel between attempts");
|
|
203
|
+
assert.deepEqual(relabels, [], "the final lane relabels ci-pending itself; the runner does not");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("#4302: a non-final attempt that ends needs-human/ci-pending without a successor is relabelled by the runner, once", async () => {
|
|
207
|
+
const relabels = [];
|
|
208
|
+
const rl = { maxAttempts: 2, relabelNeedsHuman: async (x) => { relabels.push(x); } };
|
|
209
|
+
// ci-pending on a PR that does not conflict is not recoverable: no attempt 2.
|
|
210
|
+
const a = await batch({ 1: [NH("ci.red"), { terminalReason: "ci-pending", pr: 9, detail: "CI still running" }] }, {}, { ...rl, conflicting: async () => false });
|
|
211
|
+
assert.equal(a.calls.length, 2); assert.equal(a.rows[0].outcome, "ci-pending");
|
|
212
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `ci-pending`/);
|
|
213
|
+
// needs-human with a human code is never recovered: no attempt 2.
|
|
214
|
+
relabels.length = 0;
|
|
215
|
+
const b = await batch({ 1: [NH("ci.red"), NH("builder.blocked", "needs credentials")] }, {}, rl);
|
|
216
|
+
assert.equal(b.calls.length, 2); assert.equal(b.rows[0].outcome, "needs-human");
|
|
217
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /ended `needs-human` \(`builder\.blocked`\)/);
|
|
218
|
+
// maxAttempts 1: the only attempt is final, so the lane relabels and the runner does not (unchanged).
|
|
219
|
+
relabels.length = 0;
|
|
220
|
+
const c = await batch({ 1: [NH("ci.red"), NH("builder.blocked")] }, {}, { ...rl, maxAttempts: 1 });
|
|
221
|
+
assert.deepEqual(c.calls.map((x) => x.recover), [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
222
|
+
assert.deepEqual(relabels, []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("#4315: a recovery queued when the batch stops is relabelled needs-human with the full reason", () => {
|
|
226
|
+
const LONG = `ci.red: ${"x".repeat(400)} END-OF-REASON`;
|
|
227
|
+
// issue 1 ends ci.red twice (original lane + non-final attempt 1/2); `stop` ends the batch before attempt 2 launches
|
|
228
|
+
const stopped = async (stop) => {
|
|
229
|
+
const relabels = [], calls = [];
|
|
230
|
+
let tick; const secondRun = new Promise((r) => { tick = r; });
|
|
231
|
+
const rows = await orchestrate({ issues: stop.issues ?? [1], parallel: 2, recovery: { maxAttempts: 2 }, readBody: async () => "",
|
|
232
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); },
|
|
233
|
+
runOne: async (n, _l, ctx = {}) => {
|
|
234
|
+
calls.push({ n, recover: ctx.recover ?? null });
|
|
235
|
+
if (n === 2) { await secondRun; return { terminalReason: "engine-error", detail: "boom" }; }
|
|
236
|
+
if (calls.filter((c) => c.n === 1).length === 2) tick();
|
|
237
|
+
await new Promise((r) => setImmediate(r));
|
|
238
|
+
return NH("ci.red", LONG);
|
|
239
|
+
}, ...stop.opts(calls) });
|
|
240
|
+
return { rows, relabels, calls: calls.filter((c) => c.n === 1) };
|
|
241
|
+
};
|
|
242
|
+
const check = ({ rows, relabels, calls }, cause) => {
|
|
243
|
+
assert.deepEqual(calls.map((c) => c.recover), [null, { code: "ci.red", attempt: 1, final: false }], "attempt 2 never launched");
|
|
244
|
+
assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
|
|
245
|
+
assert.match(relabels[0].evidence, /Recovery pass for `ci\.red` \(attempt 1\) did not merge: it ended `needs-human` \(`ci\.red`\)/);
|
|
246
|
+
assert.ok(relabels[0].evidence.includes(LONG), "full reason, not 300 chars");
|
|
247
|
+
assert.match(relabels[0].evidence, /Recovery attempt 2\/2 for `ci\.red` was queued, but the batch stopped \(/);
|
|
248
|
+
assert.match(relabels[0].evidence, cause);
|
|
249
|
+
const r1 = byIssue(rows)[1];
|
|
250
|
+
assert.equal(r1.outcome, "held"); assert.equal(r1.recovery.code, "ci.red"); assert.equal(r1.recovery.attempts, 2);
|
|
251
|
+
assert.match(formatOutcomeLists(rows).join("\n"), /unrecovered: #1/);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
it("breaker", async () => {
|
|
255
|
+
check(await stopped({ issues: [1, 2], opts: () => ({ breaker: { errors: 1, window_min: 30 } }) }), /circuit breaker/);
|
|
256
|
+
});
|
|
257
|
+
it("execution budget (on_budget stop)", async () => {
|
|
258
|
+
check(await stopped({ opts: (calls) => ({ remote: { max: 3, isRemote: () => true, admit: () => (calls.length >= 2 ? { ok: false, reason: "cap $5 reached" } : { ok: true }) } }) }),
|
|
259
|
+
/execution budget: cap \$5 reached/);
|
|
260
|
+
});
|
|
261
|
+
it("drift hold", async () => {
|
|
262
|
+
check(await stopped({ opts: (calls) => ({ drift: { holds: () => (calls.length >= 2 ? "packages/ui" : null) } }) }), /drift: packages\/ui flagged/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("a resume of a lost run that does not relaunch it relabels once; a second resume does not repeat it", async () => {
|
|
266
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
|
|
267
|
+
recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
|
|
268
|
+
breaker: { at: 1, detail: "circuit breaker: 3 engine-errors within 30 min; admissions stopped" } };
|
|
269
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
270
|
+
const relabels = [], ran = [];
|
|
271
|
+
const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
|
|
272
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n) => { ran.push(n); return { terminalReason: "merged" }; } });
|
|
273
|
+
const rows = await go();
|
|
274
|
+
assert.deepEqual(ran, []); assert.equal(rows[0].outcome, "held");
|
|
275
|
+
assert.equal(relabels.length, 1); assert.ok(relabels[0].evidence.includes(LONG));
|
|
276
|
+
assert.match(relabels[0].evidence, /attempt 1\) did not merge.*[\s\S]*circuit breaker/);
|
|
277
|
+
assert.deepEqual(state.relabelled, [1]);
|
|
278
|
+
await go();
|
|
279
|
+
assert.equal(relabels.length, 1, "relabelled once across resumes");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("a resume that relaunches the lost recovery lane does not relabel; the lane gets ctx.recover", async () => {
|
|
283
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
|
|
284
|
+
recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", prev_attempt: 1 } }, relabelled: [1] };
|
|
285
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
286
|
+
const relabels = [], ctxs = [];
|
|
287
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
|
|
288
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async (n, l, ctx) => { ctxs.push(ctx.recover); return { terminalReason: "merged" }; } });
|
|
289
|
+
assert.deepEqual(ctxs, [{ code: "ci.red", attempt: 2, final: true }]);
|
|
290
|
+
assert.equal(rows[0].outcome, "merged"); assert.deepEqual(relabels, []);
|
|
291
|
+
assert.equal(state.relabelled, undefined);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("a store from before #4315 (no detail, no relabelled) still resumes and relabels with the code", async () => {
|
|
295
|
+
let state = { v: 1, order: [1], deps: { 1: [] }, pending: [1], results: {}, running: {}, recoveries: { 1: 1 }, recovering: { 1: { code: "ci.red", attempt: 1 } },
|
|
296
|
+
breaker: { at: 1, detail: "circuit breaker: tripped" } };
|
|
297
|
+
const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
|
|
298
|
+
const relabels = [];
|
|
299
|
+
const rows = await orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "",
|
|
300
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); }, runOne: async () => ({ terminalReason: "merged" }) });
|
|
301
|
+
assert.equal(rows[0].outcome, "held"); assert.equal(rows[0].recovery.detail, "ci.red");
|
|
302
|
+
assert.equal(relabels.length, 1); assert.match(relabels[0].evidence, /The lane did not merge: it ended `needs-human`/);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("#4302: --recover-nonfinal reaches runCore as final: false; without it a recovery lane is final", async () => {
|
|
307
|
+
const got = [];
|
|
308
|
+
const l = console.log; console.log = () => {};
|
|
309
|
+
try {
|
|
310
|
+
for (const extra of [["--recover-nonfinal"], []]) {
|
|
311
|
+
await runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--recover", "ci.red", "--recover-attempt", "1", ...extra], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
312
|
+
leanModule: { runCore: async (o) => { got.push(o.recover); return { terminalReason: "merged" }; } } });
|
|
313
|
+
}
|
|
314
|
+
} finally { console.log = l; }
|
|
315
|
+
assert.deepEqual(got, [{ code: "ci.red", attempt: 1, final: false }, { code: "ci.red", attempt: 1, final: true }]);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("#4298 staging-review follow-ups: recovery", () => {
|
|
320
|
+
it("RECOVER_HOW has a prompt for exactly the RECOVERABLE_CODES", () => {
|
|
321
|
+
assert.deepEqual(Object.keys(RECOVER_HOW).sort(), [...RECOVERABLE_CODES].sort());
|
|
322
|
+
for (const c of RECOVERABLE_CODES) assert.ok(String(RECOVER_HOW[c]).length > 20, c);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("a recovered row reports the first attempt's cost and minutes summed with the recovery's; the lane sees only {code, attempt, final}", async () => {
|
|
326
|
+
let t = 0; const seen = [];
|
|
327
|
+
const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 0.5 }, { terminalReason: "merged", pr: 9, cost_usd: 0.25 }],
|
|
328
|
+
2: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 2 }] };
|
|
329
|
+
const k = { 1: 0, 2: 0 };
|
|
330
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => t,
|
|
331
|
+
runOne: async (n, _l, ctx = {}) => { seen.push(ctx.recover ?? null); t += 60000 * (n === 1 ? 3 : 1); return script[n][k[n]++]; } });
|
|
332
|
+
const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
|
|
333
|
+
assert.equal(r[1].outcome, "merged"); assert.equal(r[1].cost_usd, 0.75); assert.equal(r[1].minutes, 6);
|
|
334
|
+
assert.equal(r[2].outcome, "needs-human"); assert.equal(r[2].cost_usd, 3); assert.equal(r[2].minutes, 2);
|
|
335
|
+
assert.deepEqual(seen.filter(Boolean), [{ code: "ci.red", attempt: 1, final: true }, { code: "ci.red", attempt: 1, final: true }]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("formatOutcomeLists caps a detail at OUTCOME_LINE_MAX", () => {
|
|
339
|
+
const [l] = formatOutcomeLists([{ issue: 3, outcome: "needs-human", code: "conformance.omission", detail: "x".repeat(500) }]);
|
|
340
|
+
assert.ok(l.includes("x".repeat(OUTCOME_LINE_MAX)) && !l.includes("x".repeat(OUTCOME_LINE_MAX + 1)));
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("run-issue rejects --recover on the legacy --path full", async () => {
|
|
344
|
+
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "full", "--recover", "ci.red"], { dir: tmpDir("core-rec-"), emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" } }),
|
|
345
|
+
/--recover is not supported with --path full/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("/orchestrate spec describes terminal non-human needs-human rows, --recover scope and the schedd resume scope", () => {
|
|
349
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
350
|
+
assert.match(spec, /\*\*terminal code\*\* that is neither recoverable nor human/);
|
|
351
|
+
assert.match(spec, /`unrecovered: #N/);
|
|
352
|
+
assert.match(spec, /core path only:\s+`--path full` rejects `--recover`/);
|
|
353
|
+
assert.match(spec, /resumes every daemon-hosted batch that has no report yet/);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
describe("#4304 staging-review follow-ups: recovery", () => {
|
|
358
|
+
it("a recovery row counts the pre-filter cost once; a single pass still includes it", async () => {
|
|
359
|
+
const script = { 1: [{ terminalReason: "needs-human", code: "ci.red", detail: "red", cost_usd: 1 }, { terminalReason: "needs-human", code: "ci.red", detail: "red again", cost_usd: 1 }],
|
|
360
|
+
2: [{ terminalReason: "merged", pr: 4, cost_usd: 1 }] };
|
|
361
|
+
const k = { 1: 0, 2: 0 };
|
|
362
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, recovery: { maxAttempts: 1 }, readBody: async () => "", now: () => 0,
|
|
363
|
+
prefilter: async () => ({ closed: false, cost_usd: 0.1 }), runOne: async (n) => script[n][k[n]++] });
|
|
364
|
+
const r = Object.fromEntries(rows.map((x) => [x.issue, x]));
|
|
365
|
+
assert.equal(k[1], 2, "issue 1 ran a recovery lane");
|
|
366
|
+
assert.equal(r[1].cost_usd, 2.1);
|
|
367
|
+
assert.equal(r[2].cost_usd, 1.1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const runBatchWith = async (enginePath) => {
|
|
371
|
+
const leanIo = {
|
|
372
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
373
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
374
|
+
};
|
|
375
|
+
const runs = [], logs = [], l = console.log;
|
|
376
|
+
console.log = (x) => logs.push(String(x));
|
|
377
|
+
try {
|
|
378
|
+
await orchestrateFromCli(["5", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("core-rec-"), batchRoot: tmpDir("core-rec-batch-"), allowTmpfs: true, emitTelemetry: async () => {},
|
|
379
|
+
io: { gh: async () => "o/r\n" }, leanIo, sched: null, enginePath, orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } }, decide: () => {},
|
|
380
|
+
relabelNeedsHuman: async () => {},
|
|
381
|
+
leanModule: { runCore: async (o) => { runs.push(o.recover ?? null); return { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" }; } } });
|
|
382
|
+
} finally { console.log = l; }
|
|
383
|
+
return { runs, logs };
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
it("engine.path full turns batch recovery off (run-issue rejects --recover there); core keeps it", async () => {
|
|
387
|
+
const full = await runBatchWith("full");
|
|
388
|
+
assert.deepEqual(full.runs, [null]);
|
|
389
|
+
assert.ok(full.logs.some((x) => /recovery: off \(engine\.path is full/.test(x)), full.logs.join("\n"));
|
|
390
|
+
const core = await runBatchWith("core");
|
|
391
|
+
assert.deepEqual(core.runs, [null, { code: "ci.red", attempt: 1, final: true }]);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe("#4316 one shared recovery evidence text", () => {
|
|
396
|
+
it("lane, settle and batch-exit relabels render the same evidence shape", () => {
|
|
397
|
+
const long = `${"x".repeat(320)}\nsecond line`;
|
|
398
|
+
assert.equal(recoveryEvidence({ code: "ci.red", attempt: 1, outcome: "needs-human", resultCode: "ci.red", detail: " red check\nmore " }),
|
|
399
|
+
"Recovery pass for `ci.red` (attempt 1) did not merge: it ended `needs-human` (`ci.red`): red check. The issue stays `needs-human`.");
|
|
400
|
+
assert.equal(recoveryEvidence({ code: "ci.red", attempt: 2, outcome: "ci-pending", detail: "" }),
|
|
401
|
+
"Recovery pass for `ci.red` (attempt 2) did not merge: it ended `ci-pending`. The issue stays `needs-human`.");
|
|
402
|
+
assert.equal(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", detail: long }).includes(`: ${"x".repeat(300)}. The issue`), true, "first line, 300 chars");
|
|
403
|
+
const queued = recoveryEvidence({ code: "base.conflict", attempt: 0, outcome: undefined, resultCode: "base.conflict", detail: long, full: true, queued: 1, cause: "breaker", recoveryMax: 2 });
|
|
404
|
+
assert.equal(queued, `The lane did not merge: it ended \`needs-human\` (\`base.conflict\`): ${long}\n\n`
|
|
405
|
+
+ "Recovery attempt 1/2 for `base.conflict` was queued, but the batch stopped (breaker) before it launched. The issue stays `needs-human`.");
|
|
406
|
+
assert.match(recoveryEvidence({ code: "c", attempt: 1, outcome: "o", queued: 2, cause: "batch stopped" }), /^Recovery pass for `c` \(attempt 1\) did not merge: it ended `o`\n\nRecovery attempt 2\/2 /);
|
|
407
|
+
});
|
|
408
|
+
it("/orchestrate spec names the pre-filter's flaky exceptions and recovery being off on engine.path full", () => {
|
|
409
|
+
const spec = readFileSync(new URL("../../commands/orchestrate.md", import.meta.url), "utf8");
|
|
410
|
+
assert.match(spec, /unless it is flaky \(wording or `flaky` label\), was reopened after a pre-filter close, or\s+the workflow failed and passed at the same SHA/);
|
|
411
|
+
assert.match(spec, /recovery is off when `engine\.path: full`/);
|
|
412
|
+
});
|
|
413
|
+
it("orchestrate.mjs and lean.mjs build no copy of their own", () => {
|
|
414
|
+
for (const f of ["orchestrate.mjs", "lean.mjs"]) {
|
|
415
|
+
const src = readFileSync(new URL(`./${f}`, import.meta.url), "utf8");
|
|
416
|
+
assert.equal((src.match(/did not merge/g) ?? []).length, 0, f);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
});
|
package/bin/lean/render.mjs
CHANGED
|
@@ -20,6 +20,25 @@ const bullets = (v) => {
|
|
|
20
20
|
const objs = (v, key) => arr(v).map((x) => (x && typeof x === "object" ? x : { [key]: x }));
|
|
21
21
|
const norm = (r) => r ?? {};
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Untrusted issue/comment text with HTML comments removed and no comment opener left: comments are cut out by plain
|
|
25
|
+
* scanning (closer `-->` or `--!>`; an unclosed one runs to the end), then every `<` that starts `<!` becomes `‹`.
|
|
26
|
+
* With no `<!` in the output, no HTML comment and no FORGE marker can open, whatever the input nesting. Pack text is
|
|
27
|
+
* posted back in a trusted bot comment, so this is the guarantee that matters.
|
|
28
|
+
*/
|
|
29
|
+
export function noComments(s, repl = "") {
|
|
30
|
+
const t = String(s ?? "");
|
|
31
|
+
let out = "", i = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const a = t.indexOf("<!--", i);
|
|
34
|
+
if (a < 0) { out += t.slice(i); break; }
|
|
35
|
+
out += t.slice(i, a) + repl;
|
|
36
|
+
const ends = [[t.indexOf("-->", a + 4), 3], [t.indexOf("--!>", a + 4), 4]].filter(([k]) => k >= 0).sort((x, y) => x[0] - y[0]);
|
|
37
|
+
if (!ends.length) break;
|
|
38
|
+
i = ends[0][0] + ends[0][1];
|
|
39
|
+
}
|
|
40
|
+
return out.replace(/<(?=!)/g, "\u2039");
|
|
41
|
+
}
|
|
23
42
|
/** Builder-written markdown is untrusted: drop any FORGE marker comment (even unterminated ones) and normalise newlines. */
|
|
24
43
|
export function sanitizeMd(md) {
|
|
25
44
|
return String(md ?? "").replace(/\r\n/g, "\n").replace(/<!--\s*FORGE:[\s\S]*?(?:-->|$)/gi, "").trim();
|
|
@@ -31,6 +50,19 @@ export function capComment(body, max = MAX_COMMENT_CHARS) {
|
|
|
31
50
|
const note = "\n\n_[truncated: comment exceeded the size cap]_";
|
|
32
51
|
return s.slice(0, max - note.length) + note;
|
|
33
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* #4297/#4316: the one evidence text for a recovery that did not merge (lane, settle and batch-exit relabel). `attempt` 0 is
|
|
55
|
+
* the first lane; the detail is its first line capped at 300 chars unless `full`. `queued` (the attempt that never launched)
|
|
56
|
+
* adds why the batch stopped before it.
|
|
57
|
+
*/
|
|
58
|
+
export function recoveryEvidence({ code, attempt, outcome, resultCode, detail, full = false, queued = 0, cause = "", recoveryMax = 0 }) {
|
|
59
|
+
const how = attempt ? `Recovery pass for \`${code}\` (attempt ${attempt}) did not merge` : "The lane did not merge";
|
|
60
|
+
const d = String(detail ?? "").trim();
|
|
61
|
+
const shown = full ? d : d.split("\n")[0].slice(0, 300);
|
|
62
|
+
const head = `${how}: it ended \`${outcome ?? "needs-human"}\`${resultCode ? ` (\`${resultCode}\`)` : ""}${shown ? `: ${shown}` : ""}`;
|
|
63
|
+
return queued ? `${head}\n\nRecovery attempt ${queued}/${recoveryMax || queued} for \`${code}\` was queued, but the batch stopped (${cause}) before it launched. The issue stays \`needs-human\`.`
|
|
64
|
+
: `${head}. The issue stays \`needs-human\`.`;
|
|
65
|
+
}
|
|
34
66
|
const usable = (md) => sanitizeMd(md).replace(/\s+/g, " ").length >= 20;
|
|
35
67
|
|
|
36
68
|
/** Text of the `### heading` section (up to the next heading), or "". */
|
|
@@ -86,11 +118,12 @@ const esc = (v) => cell(v).replace(/</g, "<");
|
|
|
86
118
|
const escl = (v) => line(v).replace(/</g, "<");
|
|
87
119
|
/** Canonical contract JSON: stable, and `<` escaped so builder text can never open an HTML/FORGE comment. */
|
|
88
120
|
export const contractJson = (c) => JSON.stringify(c, null, 1).replace(/</g, "\\u003c");
|
|
89
|
-
|
|
121
|
+
// #4265: an optional ", issue S" records the hash of the issue scope (conformance.mjs scopeText) the contract was planned from.
|
|
122
|
+
const CONTRACT_BLOCK_RE = /<summary>contract\.json \(sha256 ([0-9a-f]{12})(?:, issue ([0-9a-f]{12}))?\)<\/summary>\s*```json\n([\s\S]*?)\n```/;
|
|
90
123
|
|
|
91
124
|
/** FORGE:CONTRACT body: readable tables plus the frozen JSON and its hash (read back on a rerun). */
|
|
92
|
-
export function renderContract(c, hash, { compact = false } = {}) {
|
|
93
|
-
const json = `<details><summary>contract.json (sha256 ${hash})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
|
|
125
|
+
export function renderContract(c, hash, { compact = false, scope = null } = {}) {
|
|
126
|
+
const json = `<details><summary>contract.json (sha256 ${hash}${scope ? `, issue ${scope}` : ""})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
|
|
94
127
|
if (compact) return `## Builder Contract (frozen \`${hash}\`)\n\n${c.requirements.length} requirement(s), ${(c.deliverables ?? []).length} deliverable(s); the tables are omitted because the contract is large.\n\n${json}`;
|
|
95
128
|
const disp = (r) => `${r.disposition}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
|
|
96
129
|
const req = c.requirements.map((r) => `| ${esc(r.id)} | ${esc(r.quote)} | ${esc(disp(r))} |`).join("\n");
|
|
@@ -99,11 +132,11 @@ export function renderContract(c, hash, { compact = false } = {}) {
|
|
|
99
132
|
? `\n\n### Decisions\n| Id | Choice | Justification | Source |\n| --- | --- | --- | --- |\n${c.decisions.map((k) => `| ${esc(k?.id)} | ${esc(k?.choice)} | ${esc(k?.justification)} | ${esc(k?.source)} |`).join("\n")}` : "";
|
|
100
133
|
return `## Builder Contract (frozen \`${hash}\`)\n\n### Requirements\n| Id | From the issue | Disposition |\n| --- | --- | --- |\n${req}\n\n### Deliverables\n${del ? `| Id | Path | Change | Requirements |\n| --- | --- | --- | --- |\n${del}` : "None."}\n\n### Preserve\n${(c.preserve ?? []).map((p) => `- ${escl(p.id)}: ${escl(p.what)}`).join("\n") || "None."}\n\n### Validate\n${(c.validate ?? []).map((v) => `- ${escl(v.id)}: \`${escl(v.command).replace(/`/g, "'")}\``).join("\n") || NR}${dec}\n\n${json}`;
|
|
101
134
|
}
|
|
102
|
-
/** {contract, hash, text} from a FORGE:CONTRACT comment body, or null. */
|
|
135
|
+
/** {contract, hash, scope, text} from a FORGE:CONTRACT comment body (scope null for a pre-#4265 comment), or null. */
|
|
103
136
|
export function parseContractComment(body) {
|
|
104
137
|
const m = CONTRACT_BLOCK_RE.exec(String(body ?? ""));
|
|
105
138
|
if (!m) return null;
|
|
106
|
-
try { return { hash: m[1],
|
|
139
|
+
try { return { hash: m[1], scope: m[2] ?? null, text: m[3], contract: JSON.parse(m[3]) }; } catch { return null; }
|
|
107
140
|
}
|
|
108
141
|
|
|
109
142
|
/** Typed amendments, posted visibly before review. */
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
/**
|
|
5
|
+
* Optional repo caches for the pre-PR step (#4272): `.forge/test-map.json` (scripts/test-map.sh, file glob → check
|
|
6
|
+
* command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
|
|
7
|
+
* the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
|
|
8
|
+
* the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
|
|
9
|
+
* or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
|
|
10
|
+
*
|
|
11
|
+
* node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { globToRegex, shellQuote } from "../../scripts/ci-local/lib.mjs";
|
|
17
|
+
|
|
18
|
+
export const TEST_MAP_FILE = ".forge/test-map.json";
|
|
19
|
+
export const REPO_FACTS_FILE = ".forge/repo-facts.json";
|
|
20
|
+
|
|
21
|
+
/** A parsed test map when valid ({version:1, entries:[{command, globs:string[]}]}), else null. */
|
|
22
|
+
export function parseTestMap(text) {
|
|
23
|
+
let j;
|
|
24
|
+
try { j = JSON.parse(text); } catch { return null; }
|
|
25
|
+
if (!j || j.version !== 1 || !Array.isArray(j.entries)) return null;
|
|
26
|
+
const ok = j.entries.every((e) => e && typeof e.command === "string" && e.command.trim() && Array.isArray(e.globs) && e.globs.length && e.globs.every((g) => typeof g === "string" && g));
|
|
27
|
+
return ok ? j : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The first valid test map under `dirs` (in order), or null; never throws. */
|
|
31
|
+
export async function readTestMap(dirs, read) {
|
|
32
|
+
if (typeof read !== "function") return null;
|
|
33
|
+
for (const d of dirs.filter(Boolean)) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = await read(join(d, TEST_MAP_FILE)); } catch { continue; }
|
|
36
|
+
const map = parseTestMap(String(text ?? ""));
|
|
37
|
+
if (map) return map;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** #4297: the valid test map committed at `ref` (`git show <ref>:.forge/test-map.json`), or null; never throws. */
|
|
43
|
+
export async function readTestMapAtRef(git, ref) {
|
|
44
|
+
if (typeof git !== "function" || !ref) return null;
|
|
45
|
+
try { return parseTestMap(String((await git(["show", `${ref}:${TEST_MAP_FILE}`])) ?? "")); } catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Commands of entries with a glob matching a changed file: map order, deduped; a non-root cwd runs as `cd <cwd> && cmd`. */
|
|
49
|
+
export function selectCommands(map, files) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const changed = (Array.isArray(files) ? files : []).map((f) => String(f).replace(/^\.\//, ""));
|
|
52
|
+
for (const e of map?.entries ?? []) {
|
|
53
|
+
const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
|
|
54
|
+
if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
|
|
55
|
+
const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
|
|
56
|
+
const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
|
|
57
|
+
if (!out.includes(cmd)) out.push(cmd);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
|
|
64
|
+
* whitespace-collapsed, so the map can only add checks, never remove one. `source`: "learned" when the map added
|
|
65
|
+
* nothing, "test-map" when there were no learned commands, else "test-map+learned".
|
|
66
|
+
* @returns {{commands:string[], source:string, added:number}}
|
|
67
|
+
*/
|
|
68
|
+
export function mergeChecks(selected, learned) {
|
|
69
|
+
const norm = (c) => String(c ?? "").trim().replace(/\s+/g, " ");
|
|
70
|
+
const commands = [], seen = new Set();
|
|
71
|
+
const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
|
|
72
|
+
for (const c of Array.isArray(learned) ? learned : []) add(c);
|
|
73
|
+
const base = commands.length;
|
|
74
|
+
let added = 0;
|
|
75
|
+
for (const c of Array.isArray(selected) ? selected : []) if (add(c)) added++;
|
|
76
|
+
return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
|
|
80
|
+
export async function factsPath(dirs, exists) {
|
|
81
|
+
if (typeof exists !== "function") return null;
|
|
82
|
+
for (const d of dirs.filter(Boolean)) {
|
|
83
|
+
const p = join(d, REPO_FACTS_FILE);
|
|
84
|
+
try { if (await exists(p)) return p; } catch { /* absent */ }
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function main(argv) {
|
|
90
|
+
const i = argv.indexOf("--map"), j = argv.indexOf("--select");
|
|
91
|
+
if (i < 0 || !argv[i + 1] || j !== i + 2) { console.error("usage: testmap.mjs --map FILE --select FILE..."); return 2; }
|
|
92
|
+
let text;
|
|
93
|
+
try { text = fs.readFileSync(argv[i + 1], "utf8"); } catch { console.error(`testmap: cannot read ${argv[i + 1]}`); return 2; }
|
|
94
|
+
const map = parseTestMap(text);
|
|
95
|
+
if (!map) { console.error("testmap: invalid test map"); return 2; }
|
|
96
|
+
const files = argv.slice(j + 1);
|
|
97
|
+
for (const c of selectCommands(map, files)) console.log(c);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|