forgedock 1.30.0 → 1.31.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 +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +362 -33
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +294 -1
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +116 -43
- package/bin/lean/context.test.mjs +139 -4
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +37 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +650 -124
- package/bin/lean/lean.test.mjs +1450 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +261 -38
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +113 -1
- package/bin/lean/prefilter.mjs +159 -5
- package/bin/lean/prefilter.test.mjs +152 -3
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +101 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -5,7 +5,9 @@ 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 {
|
|
8
|
+
import { formatFirstPassSummary } from "./fixround.mjs";
|
|
9
|
+
import { runChecks, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
9
11
|
import { readPrecheckBudget } from "../engine-cli.mjs";
|
|
10
12
|
import { orchestrate } from "./orchestrate.mjs";
|
|
11
13
|
|
|
@@ -66,6 +68,43 @@ describe("runChecks (#4331)", () => {
|
|
|
66
68
|
});
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
describe("runChecks (#4344)", () => {
|
|
72
|
+
/** A fake heavy semaphore: counts live slots, their peak and releases; `wait` ms of the shared clock per acquire. */
|
|
73
|
+
const sem = (h, wait = 0) => { const s = { live: 0, peak: 0, acquired: 0, released: 0 };
|
|
74
|
+
s.acquire = async () => { s.acquired++; s.peak = Math.max(s.peak, ++s.live); h.t += wait; return { release: () => { s.live--; s.released++; } }; }; return s; };
|
|
75
|
+
|
|
76
|
+
it("#4344 divides the worker budget across concurrent commands and takes one heavy slot per running command", async () => {
|
|
77
|
+
for (const [n, cmds, share, peak] of [[8, ["a", "b", "c", "d", "e", "f", "g", "h"], 1, 8], [8, ["a", "b"], 4, 2], [6, ["a", "b", "c", "d"], 1, 4], [5, ["a", "b"], 2, 2], [4, ["a"], 4, 1]]) {
|
|
78
|
+
const h = harness(), s = sem(h), seen = [];
|
|
79
|
+
await runChecks({ commands: cmds, parallelism: n, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
|
|
80
|
+
assert.ok(seen.every((w) => w === share), `N=${n} k=${cmds.length}: ${seen}`);
|
|
81
|
+
assert.ok(share * h.peak <= n, `round total ${share * h.peak} workers > N=${n}`);
|
|
82
|
+
assert.deepEqual([s.peak, s.acquired, s.released, s.live], [peak, cmds.length, cmds.length, 0], `N=${n} slots`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("#4344 FORGE_HOST_PARALLELISM=1: one command at a time, one worker, one slot", async () => {
|
|
87
|
+
const h = harness(), s = sem(h), seen = [];
|
|
88
|
+
await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
|
|
89
|
+
assert.deepEqual([h.peak, s.peak, seen], [1, 1, [1, 1, 1]]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("#4344 the slot wait counts against the budget: a command whose wait exhausts it is deferred, its slot released", async () => {
|
|
93
|
+
const h = harness(), s = sem(h, 1000);
|
|
94
|
+
const r = await runChecks({ commands: ["a", "b"], parallelism: 1, budgetMs: 2500, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: h.run });
|
|
95
|
+
assert.deepEqual(h.calls, [["a", 1500]]);
|
|
96
|
+
assert.deepEqual([r.results.map((x) => x.command), r.deferred], [["a"], ["b"]]);
|
|
97
|
+
assert.deepEqual([s.acquired, s.released], [2, 2]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("#4344 a throwing acquire runs the command ungated; a throwing release is ignored", async () => {
|
|
101
|
+
const h = harness();
|
|
102
|
+
const r1 = await runChecks({ commands: ["a"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => { throw new Error("registry"); }, run: h.run });
|
|
103
|
+
const r2 = await runChecks({ commands: ["b"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => ({ release: () => { throw new Error("x"); } }), run: h.run });
|
|
104
|
+
assert.deepEqual([...r1.results, ...r2.results].map((x) => [x.command, x.rc]), [["a", 0], ["b", 0]]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
69
108
|
describe("formatPrecheckSummary (#4331)", () => {
|
|
70
109
|
it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
|
|
71
110
|
assert.equal(formatPrecheckSummary([{ precheck_ms: 90000 }, { precheck_ms: 30000, precheck_deferred: 2 }, { issue: 3 }]), "precheck: 2.0m total over 2 lanes, 2 deferred to CI");
|
|
@@ -79,6 +118,12 @@ describe("formatPrecheckSummary (#4331)", () => {
|
|
|
79
118
|
assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
|
|
80
119
|
assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
|
|
81
120
|
});
|
|
121
|
+
it("#4362: orchestrate rows carry the lane's firstPass as first_pass for the batch first-pass line", async () => {
|
|
122
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
|
|
123
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { firstPass: true }, 2: { firstPass: false } })[n] }) });
|
|
124
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.first_pass]), [[1, true], [2, false], [3, undefined]]);
|
|
125
|
+
assert.equal(formatFirstPassSummary(rows), "first-pass: 50% (1/2 lanes that reached the build)");
|
|
126
|
+
});
|
|
82
127
|
});
|
|
83
128
|
|
|
84
129
|
describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
|
|
@@ -91,3 +136,70 @@ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", ()
|
|
|
91
136
|
assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
|
|
92
137
|
});
|
|
93
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("forgedock check core (#4364)", () => {
|
|
141
|
+
const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
|
|
142
|
+
|
|
143
|
+
it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
|
|
144
|
+
const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
|
|
145
|
+
assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
|
|
146
|
+
assert.equal(p.selected, 1); assert.equal(p.budgetMs, 120000); assert.equal(p.perCommandMs, 30000);
|
|
147
|
+
assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 2, verify: { precheckTotalSeconds: 999 } }).budgetMs, 120000);
|
|
148
|
+
assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 3, verify: {} }).budgetMs, 180000);
|
|
149
|
+
assert.deepEqual(planPrecheck({ learned: ["x"], verify: {} }), { commands: ["x"], added: 0, dropped: [], source: "learned", selected: 0, budgetMs: 600000, perCommandMs: 300000 });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("selfCheck runs validates then pre-PR commands; ok only when every run exits 0; ci mode leaves validates to CI", async () => {
|
|
153
|
+
const h = harness({ rc: { "make lint": 2 } });
|
|
154
|
+
const contract = { validate: [{ id: "V1", command: "node --test a" }, { id: "V2", command: " " }] };
|
|
155
|
+
const plan = { mode: "local", commands: ["npm test", "make lint"], budgetMs: 600000, perCommandMs: 300000 };
|
|
156
|
+
const r = await selfCheck({ contract, plan, now: h.now, run: h.run });
|
|
157
|
+
assert.deepEqual(h.calls.map((c) => c[0]), ["node --test a", "npm test", "make lint"]);
|
|
158
|
+
assert.deepEqual(r.validate.map((x) => [x.id, x.rc]), [["V1", 0]]); assert.deepEqual(r.commands.map((x) => x.rc), [0, 2]); assert.equal(r.ok, false);
|
|
159
|
+
const h2 = harness();
|
|
160
|
+
const r2 = await selfCheck({ contract, plan: { ...plan, mode: "ci" }, now: h2.now, run: h2.run });
|
|
161
|
+
assert.deepEqual(h2.calls.map((c) => c[0]), ["npm test", "make lint"]); assert.deepEqual(r2.validate, [{ id: "V1", command: "node --test a", status: "ci" }]); assert.equal(r2.ok, true);
|
|
162
|
+
const h3 = harness({ cost: { "npm test": 5000 } });
|
|
163
|
+
const r3 = await selfCheck({ contract: {}, plan: { commands: ["npm test", "make lint"], budgetMs: 4000, perCommandMs: 300000 }, now: h3.now, run: h3.run });
|
|
164
|
+
assert.deepEqual(r3.deferred, ["npm test", "make lint"]); assert.equal(r3.ok, true, "deferred to CI is not a failure, as in the engine");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("selfCheckState and formatCheckTable", () => {
|
|
168
|
+
assert.equal(selfCheckState(null, "f"), "skipped"); assert.equal(selfCheckState({ fingerprint: "g", ok: true }, "f"), "stale");
|
|
169
|
+
assert.equal(selfCheckState({ fingerprint: "f", ok: false }, "f"), "red"); assert.equal(selfCheckState({ fingerprint: "f", ok: true }, "f"), "green");
|
|
170
|
+
const t = formatCheckTable({ ok: false, validate: [{ id: "V1", command: "v", rc: 0, ms: 1200 }], commands: [{ command: "make lint", rc: 2, ms: 10, output: `${"x".repeat(5000)}TAIL` }], deferred: ["slow"] }, 1000);
|
|
171
|
+
assert.match(t, /^PASS +1\.2s {2}V1 v$/m); assert.match(t, /^FAIL exit 2 .* make lint$/m); assert.match(t, /^DEFERRED .* slow$/m);
|
|
172
|
+
assert.match(t, /--- make lint \(exit 2\)\n…x+TAIL/); assert.ok(t.length < 1400, "failure output capped"); assert.match(t, /check: FAIL \(1 failing\)$/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("#4390 quietFailure: last 40 lines, failing ids across ecosystems, log pointer; short output unchanged", () => {
|
|
176
|
+
const out = ["FAILED tests/test_x.py::test_a - assert 1 == 2", "not ok 3 - parses dates", " FAIL src/a.test.ts > adds", "--- FAIL: TestSum (0.00s)", "test parser::empty ... FAILED", " ✕ renders header (5 ms)",
|
|
177
|
+
...Array.from({ length: 100 }, (_, i) => `line ${i}`)].join("\n");
|
|
178
|
+
const q = quietFailure(out, { logPath: ".forge-lean/logs/check-V1.log" });
|
|
179
|
+
assert.match(q, /^…line 60\n/); assert.match(q, /line 99\nfailing tests:\n/);
|
|
180
|
+
for (const id of ["FAILED tests/test_x.py::test_a", "not ok 3 - parses dates", "FAIL src/a.test.ts > adds", "--- FAIL: TestSum", "test parser::empty ... FAILED", "✕ renders header"]) assert.ok(q.includes(` ${id}`), id);
|
|
181
|
+
assert.match(q, /\nfull log: \.forge-lean\/logs\/check-V1\.log$/);
|
|
182
|
+
assert.equal(quietFailure("ok\nall good\n"), "ok\nall good");
|
|
183
|
+
assert.equal(quietFailure(""), "(no output)");
|
|
184
|
+
assert.equal(quietFailure("x".repeat(50), { maxChars: 10 }), `…${"x".repeat(10)}`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("worktreeFingerprint: the same content committed or not is the same tree; an edit or new file changes it; .forge-lean is ignored", async () => {
|
|
188
|
+
const dir = tmpDir("selfcheck-fp-");
|
|
189
|
+
const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
|
|
190
|
+
const g = (a) => execFileSync("git", a, { cwd: dir, encoding: "utf8", env });
|
|
191
|
+
g(["init", "-q", "-b", "main"]); writeFileSync(join(dir, "a.js"), "1\n"); writeFileSync(join(dir, "gone.js"), "x\n"); g(["add", "-A"]); g(["commit", "-q", "-m", "base"]);
|
|
192
|
+
g(["branch", "base"]);
|
|
193
|
+
const fp = () => worktreeFingerprint({ git: async (a) => g(a), base: "base" });
|
|
194
|
+
const clean = await fp();
|
|
195
|
+
writeFileSync(join(dir, "a.js"), "2\n"); writeFileSync(join(dir, "new.js"), "n\n"); g(["rm", "-q", "gone.js"]);
|
|
196
|
+
const dirty = await fp(); assert.notEqual(dirty, clean);
|
|
197
|
+
execFileSync("mkdir", ["-p", join(dir, ".forge-lean")]); writeFileSync(join(dir, ".forge-lean", "result.json"), "{}");
|
|
198
|
+
g(["add", "-A"]); g(["reset", "-q", "--", ".forge-lean"]);
|
|
199
|
+
writeFileSync(join(dir, ".git", "info", "exclude"), ".forge-lean/\n");
|
|
200
|
+
g(["commit", "-q", "-m", "work"]);
|
|
201
|
+
assert.equal(await fp(), dirty, "committing the checked tree does not make the check stale");
|
|
202
|
+
writeFileSync(join(dir, "a.js"), "3\n");
|
|
203
|
+
assert.notEqual(await fp(), dirty);
|
|
204
|
+
});
|
|
205
|
+
});
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -10,12 +10,17 @@
|
|
|
10
10
|
* 3. every repo path the issue names is gone from origin/<lane> -> candidate
|
|
11
11
|
* A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
|
|
12
12
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
13
|
-
*
|
|
13
|
+
* Post-deploy gate (#4389), with `defaultBranch`: a `FORGE:DEFERRED_FROM: #P` follow-up whose checklist items are all
|
|
14
|
+
* post-deploy is parked (`parked: true`, label `awaiting-promotion`, no lane) while a merge SHA of #P is not an ancestor
|
|
15
|
+
* of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. Anything unresolvable fails open.
|
|
16
|
+
* All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
|
|
14
17
|
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
15
19
|
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
16
20
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
17
21
|
import { isTrustedComment } from "../engine/phases.mjs";
|
|
18
22
|
import { APP_SLUG } from "../init-repo.mjs";
|
|
23
|
+
import { probeResultOf } from "./probe.mjs";
|
|
19
24
|
|
|
20
25
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
21
26
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -78,11 +83,15 @@ export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
|
78
83
|
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
79
84
|
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
80
85
|
const mine = new Set((own ?? []).map(bareLogin));
|
|
86
|
+
// #4399: the latest pipeline FORGE:PROBE result is a fail: the fix did not hold in production, so no already-fixed close
|
|
87
|
+
const probe = (iss.comments ?? []).filter((c) => probeResultOf(c?.body) && (trusted(c) || mine.has(bareLogin(loginOf(c))))).at(-1);
|
|
88
|
+
if (probe && probeResultOf(probe.body).result === "fail") return { reason: "probe-failed", why: "acceptance probe failed after promotion" };
|
|
81
89
|
if ((iss.comments ?? []).some((c) => isMarked(c) && (trusted(c) || mine.has(bareLogin(loginOf(c)))))) return { reason: "reopened", why: "reopened after a pre-filter close" };
|
|
82
90
|
return null;
|
|
83
91
|
}
|
|
84
92
|
/** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
|
|
85
|
-
const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => isMarked(c) && !trusted(c)) ? resolve() : undefined);
|
|
93
|
+
const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => (isMarked(c) || probeResultOf(c?.body)) && !trusted(c)) ? resolve() : undefined);
|
|
94
|
+
const selfResolver = (selfLogins, gh) => (typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(gh));
|
|
86
95
|
|
|
87
96
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
88
97
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
@@ -115,7 +124,7 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
115
124
|
const sha = latest.head_sha ?? "";
|
|
116
125
|
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
117
126
|
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
118
|
-
const own = await ownFor(iss,
|
|
127
|
+
const own = await ownFor(iss, selfResolver(selfLogins, io.gh));
|
|
119
128
|
let skip = flakyReason(iss, own);
|
|
120
129
|
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
121
130
|
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
@@ -139,21 +148,166 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
139
148
|
return sig;
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
// #4389: post-deploy precondition gate. A follow-up filed by scripts/file-deferred-followups.sh whose items are all
|
|
152
|
+
// post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
|
|
153
|
+
export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
|
|
154
|
+
export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
|
|
155
|
+
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
156
|
+
/** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
|
|
157
|
+
export function deferredFrom(body) {
|
|
158
|
+
const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
|
|
159
|
+
return m ? Number(m[1]) : null;
|
|
160
|
+
}
|
|
161
|
+
const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
162
|
+
/** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
|
|
163
|
+
export function postDeployOnly(body) {
|
|
164
|
+
const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
|
|
165
|
+
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
|
|
166
|
+
}
|
|
167
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
168
|
+
/**
|
|
169
|
+
* #4467: an issue comment the engine may trust, in REST shape (`user.type`) or `gh issue view` shape (`author.login`,
|
|
170
|
+
* `authorAssociation`): what isTrustedComment trusts, or the ForgeDock App, which `gh issue view` reports as a bare
|
|
171
|
+
* `rapiercraft-forgedock` CONTRIBUTOR with no type and no `[bot]` suffix (#4315).
|
|
172
|
+
*/
|
|
173
|
+
export const isTrustedIssueComment = (c) => Boolean(c) && (trusted(c) || bareLogin(loginOf(c)) === bareLogin(APP_SLUG));
|
|
174
|
+
/** Merge commits of the merged PRs that claim to fix #parent; else the `merged at <sha>` of its trusted FORGE:TRAJECTORY. */
|
|
175
|
+
export async function parentMergeShas({ parent, repo, io }) {
|
|
176
|
+
const shas = new Set();
|
|
177
|
+
try {
|
|
178
|
+
const prs = JSON.parse(String(await io.gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${parent}`, "--json", "number,title,body,mergeCommit", "--limit", "20"])) || "[]");
|
|
179
|
+
for (const p of prs) if ((claimsFix(p.title, parent) || claimsFix(p.body, parent)) && SHA_RE.test(p.mergeCommit?.oid ?? "")) shas.add(p.mergeCommit.oid);
|
|
180
|
+
} catch { /* unreadable: try the trajectory */ }
|
|
181
|
+
if (!shas.size) {
|
|
182
|
+
try {
|
|
183
|
+
const v = JSON.parse(String(await io.gh(["issue", "view", String(parent), "-R", repo, "--json", "comments"])));
|
|
184
|
+
for (const c of v.comments ?? []) {
|
|
185
|
+
const b = String(c?.body ?? "");
|
|
186
|
+
const m = b.includes("FORGE:TRAJECTORY") && isTrustedIssueComment(c) ? /merged at `?([0-9a-f]{7,40})\b/i.exec(b) : null;
|
|
187
|
+
if (m) shas.add(m[1]);
|
|
188
|
+
}
|
|
189
|
+
} catch { /* unreadable: no SHA */ }
|
|
190
|
+
}
|
|
191
|
+
return [...shas];
|
|
192
|
+
}
|
|
193
|
+
/** Default `io.execCode`: the exit code of an argv-only child (-1 when it could not run or timed out). */
|
|
194
|
+
function execCode(cmd, args, opts) {
|
|
195
|
+
return new Promise((res) => {
|
|
196
|
+
try { execFile(cmd, args, opts, (err) => res(err ? (typeof err.code === "number" ? err.code : -1) : 0)); } catch { res(-1); }
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
|
|
201
|
+
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, detail:string}>}
|
|
202
|
+
* Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
|
|
203
|
+
*/
|
|
204
|
+
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null }) {
|
|
205
|
+
const parent = deferredFrom(iss?.body);
|
|
206
|
+
const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
|
|
207
|
+
if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
|
|
208
|
+
try {
|
|
209
|
+
const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
|
|
210
|
+
if (!def) return res("unresolved", "default branch unknown");
|
|
211
|
+
const shas = await parentMergeShas({ parent, repo, io });
|
|
212
|
+
if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
|
|
213
|
+
const off = [];
|
|
214
|
+
for (const sha of shas) {
|
|
215
|
+
let on;
|
|
216
|
+
try { await io.git(["merge-base", "--is-ancestor", sha, `origin/${def}`], { cwd }); on = true; } catch (e) { on = e?.code === 1 ? false : null; }
|
|
217
|
+
if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
|
|
218
|
+
if (!on) off.push(sha);
|
|
219
|
+
}
|
|
220
|
+
if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of #${parent} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
|
|
221
|
+
if (preconditionCmd) {
|
|
222
|
+
const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
|
|
223
|
+
{ cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
|
|
224
|
+
if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
|
|
225
|
+
if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
|
|
226
|
+
}
|
|
227
|
+
return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
|
|
228
|
+
} catch (e) {
|
|
229
|
+
return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* #4389: the default branch for the gate, resolved and fetched once, only when a gated issue asks: forge.yaml
|
|
234
|
+
* `branches.default`, else GitHub's default branch. Null when neither answers (the gate then fails open).
|
|
235
|
+
*/
|
|
236
|
+
export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
|
|
237
|
+
let p = null;
|
|
238
|
+
return () => (p ??= (async () => {
|
|
239
|
+
let name = configured;
|
|
240
|
+
if (!name) { try { name = String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])).trim() || null; } catch { name = null; } }
|
|
241
|
+
if (name) { try { await io.git(["fetch", "origin", name], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
242
|
+
return name;
|
|
243
|
+
})());
|
|
244
|
+
}
|
|
245
|
+
/** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
|
|
246
|
+
async function markParked({ issue, repo, io, had, gate }) {
|
|
247
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
|
|
248
|
+
try { await edit(); } catch {
|
|
249
|
+
try {
|
|
250
|
+
await io.gh(["label", "create", AWAITING_PROMOTION_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
|
|
251
|
+
await edit();
|
|
252
|
+
} catch { /* the comment and the report still show it */ }
|
|
253
|
+
}
|
|
254
|
+
if (had) return;
|
|
255
|
+
const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : "awaiting its post-deploy precondition";
|
|
256
|
+
const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
|
|
257
|
+
try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting precondition:` line) for the parked rows, or "". */
|
|
261
|
+
export function formatParkedLine(rows) {
|
|
262
|
+
const parked = (rows ?? []).filter((r) => r?.prefilter?.parked);
|
|
263
|
+
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
264
|
+
const out = [];
|
|
265
|
+
const promo = parked.filter((r) => r.prefilter.kind === "awaiting-promotion");
|
|
266
|
+
if (promo.length) {
|
|
267
|
+
const lanes = uniq(promo.map((r) => r.prefilter.lane)), defs = uniq(promo.map((r) => r.prefilter.default));
|
|
268
|
+
out.push(`awaiting promotion: ${promo.map((r) => `#${r.issue}`).join(" ")} (fixes on ${lanes.join(", ") || "the lane"}, not on ${defs.join(", ") || "the default branch"})`);
|
|
269
|
+
}
|
|
270
|
+
const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
|
|
271
|
+
if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
|
|
272
|
+
return out.join("\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
142
275
|
/**
|
|
143
276
|
* Pre-filter one issue. Never throws.
|
|
144
277
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
145
278
|
*/
|
|
146
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
|
|
279
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null }) {
|
|
147
280
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
148
281
|
try {
|
|
149
282
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
150
283
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
151
|
-
|
|
284
|
+
const names = (iss.labels ?? []).map((l) => l?.name ?? l);
|
|
285
|
+
const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
|
|
286
|
+
// #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
|
|
287
|
+
const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
|
|
288
|
+
if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
|
|
152
289
|
if (io.projector) {
|
|
153
290
|
let st = null;
|
|
154
291
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
155
292
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
156
293
|
}
|
|
294
|
+
if (gated) {
|
|
295
|
+
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd });
|
|
296
|
+
const had = names.includes(AWAITING_PROMOTION_LABEL);
|
|
297
|
+
if (gate.state === "awaiting-promotion" || gate.state === "awaiting-precondition") {
|
|
298
|
+
if (!dryRun) await markParked({ issue, repo, io, had, gate });
|
|
299
|
+
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
|
|
300
|
+
}
|
|
301
|
+
if (gate.state === "ready" && had && !dryRun) {
|
|
302
|
+
try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
|
|
303
|
+
}
|
|
304
|
+
if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
|
|
305
|
+
}
|
|
306
|
+
// #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
|
|
307
|
+
if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
|
|
308
|
+
&& flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
|
|
309
|
+
return { ...out, detail: "acceptance probe failed after promotion (FORGE:PROBE): no already-fixed close; lane" };
|
|
310
|
+
}
|
|
157
311
|
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
158
312
|
const close = async (kind, body, detail) => {
|
|
159
313
|
let note = "";
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, flakyReason, makePrefilter, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
|
-
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
7
|
+
import { orchestrate, formatTable, formatOutcomeLists } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
9
9
|
|
|
10
10
|
const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
@@ -13,7 +13,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
|
13
13
|
function world(o = {}) {
|
|
14
14
|
const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
|
|
15
15
|
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {}, shaRuns: o.shaRuns ?? {},
|
|
16
|
-
verdict: o.verdict ?? null };
|
|
16
|
+
verdict: o.verdict ?? null, mergedPrs: o.mergedPrs ?? [], ancestry: o.ancestry ?? {}, edits: [] };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
19
19
|
const k = a.slice(0, 2).join(" ");
|
|
@@ -30,6 +30,8 @@ function world(o = {}) {
|
|
|
30
30
|
}
|
|
31
31
|
if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
|
|
32
32
|
if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
|
|
33
|
+
if (k === "pr list") return JSON.stringify(w.mergedPrs);
|
|
34
|
+
if (k === "issue edit") { w.edits.push([Number(a[2]), a[5], a[6]]); return ""; }
|
|
33
35
|
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
34
36
|
},
|
|
35
37
|
git: async (a) => {
|
|
@@ -37,6 +39,7 @@ function world(o = {}) {
|
|
|
37
39
|
if (a[0] === "log") return w.commits;
|
|
38
40
|
if (a[0] === "cat-file") { if (w.paths.has(a[2].replace(/^origin\/staging:/, ""))) return ""; throw new Error("missing"); }
|
|
39
41
|
if (a[0] === "fetch") return "";
|
|
42
|
+
if (a[0] === "merge-base") { const c = w.ancestry[a[2]] ?? 128; if (c === 0) return ""; throw Object.assign(new Error(`exit ${c}`), { code: c }); }
|
|
40
43
|
if (a[0] === "ls-remote") return a.at(-1) === "refs/heads/staging" ? "abc\trefs/heads/staging\n" : "";
|
|
41
44
|
throw new Error(`unexpected git ${a.join(" ")}`);
|
|
42
45
|
},
|
|
@@ -71,6 +74,20 @@ describe("pre-filter (#4123)", () => {
|
|
|
71
74
|
assert.match(w.comments[0][1], /FORGE:PREFILTER/); assert.match(w.comments[0][1], /src\/foo\.js:3 null guard/); assert.match(w.comments[0][1], /PR #90/);
|
|
72
75
|
});
|
|
73
76
|
|
|
77
|
+
it("#4399 an issue reopened by a failed acceptance probe is never re-closed as already-fixed; a pass is no skip", async () => {
|
|
78
|
+
const probe = (result, author = { login: "rapiercraft-forgedock" }, assoc = "CONTRIBUTOR") => ({ author, authorAssociation: assoc,
|
|
79
|
+
body: `<!-- FORGE:PROBE -->\n## Acceptance probe: ${result}\n${JSON.stringify({ fix: "abc1234", default_sha: "d", result, exit: result === "pass" ? 0 : 1, ms: 5 })}` });
|
|
80
|
+
assert.deepEqual(flakyReason({ comments: [probe("fail")] }), { reason: "probe-failed", why: "acceptance probe failed after promotion" });
|
|
81
|
+
assert.equal(flakyReason({ comments: [probe("fail"), probe("pass")] }), null);
|
|
82
|
+
assert.equal(flakyReason({ comments: [probe("fail", { login: "someone" }, "NONE")] }), null);
|
|
83
|
+
const w = world({ issues: { 12: { title: "foo breaks", body: "foo crashes on null", comments: [probe("fail")] } },
|
|
84
|
+
prs: [{ number: 90, title: "fix: guard foo", body: "Fixes #12", url: "https://github.com/o/r/pull/90" }],
|
|
85
|
+
verdict: { verdict: "ALREADY-FIXED", summary: "guard added", evidence: ["src/foo.js:3"] } });
|
|
86
|
+
const r = await pf(w, 12);
|
|
87
|
+
assert.equal(r.closed, false); assert.match(r.detail, /acceptance probe failed after promotion/);
|
|
88
|
+
assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
|
|
89
|
+
});
|
|
90
|
+
|
|
74
91
|
it("a candidate the validator CONFIRMS (or answers without evidence) is not closed", async () => {
|
|
75
92
|
const mk = (verdict) => world({ issues: { 12: { body: "x" } }, prs: [{ number: 90, title: "fix (#12)", body: "" }], verdict });
|
|
76
93
|
for (const v of [{ verdict: "CONFIRMED", evidence: ["src/foo.js:3 still derefs"] }, { verdict: "ALREADY-FIXED", evidence: [] }, null]) {
|
|
@@ -338,3 +355,135 @@ describe("#4318: the engine's own identity under an installation token or a diff
|
|
|
338
355
|
assert.equal(lookups, 4, "user + app, twice: one retry, then cached");
|
|
339
356
|
});
|
|
340
357
|
});
|
|
358
|
+
|
|
359
|
+
describe("post-deploy precondition gate (#4389)", () => {
|
|
360
|
+
const MERGE = "a".repeat(40);
|
|
361
|
+
const item = (id, disp) => `- [ ] \`${id}: After the next scheduled run, a health row exists\` — ${disp}: only checkable in prod after promotion`;
|
|
362
|
+
const followup = (parent, items) => `## Problem\n\nDeferred from #${parent}: the items below were declared out of scope.\n\n## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #${parent} -->\n`;
|
|
363
|
+
const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
|
|
364
|
+
const gated = (w, n, extra = {}) => pf(w, n, { defaultBranch: "main", ...extra });
|
|
365
|
+
|
|
366
|
+
it("parses the marker and requires every checklist item to be post-deploy", () => {
|
|
367
|
+
const body = followup(40, [item("R2", "post-deploy")]);
|
|
368
|
+
assert.equal(deferredFrom(body), 40);
|
|
369
|
+
assert.equal(deferredFrom("no marker"), null);
|
|
370
|
+
assert.ok(postDeployOnly(body));
|
|
371
|
+
assert.ok(!postDeployOnly(followup(40, [item("R2", "post-deploy"), item("R3", "defer")])));
|
|
372
|
+
assert.ok(!postDeployOnly("no checklist"));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("not an ancestor of the default branch: parked awaiting-promotion, labelled and explained once, no validation run, $0", async () => {
|
|
376
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
377
|
+
const r = await gated(w, 41);
|
|
378
|
+
assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-promotion"); assert.equal(r.closed, false); assert.equal(r.cost_usd, 0);
|
|
379
|
+
assert.match(r.detail, /of #40 is not on main/);
|
|
380
|
+
assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-promotion"]]);
|
|
381
|
+
assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:PRECONDITION/);
|
|
382
|
+
assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []);
|
|
383
|
+
// already labelled: no second comment
|
|
384
|
+
const w2 = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
385
|
+
assert.equal((await gated(w2, 41)).parked, true);
|
|
386
|
+
assert.equal(w2.comments.length, 0);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("an escalated (needs-human) promotion follow-up is parked too; a dry run changes nothing on GitHub", async () => {
|
|
390
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
391
|
+
const r = await gated(w, 41, { dryRun: true });
|
|
392
|
+
assert.equal(r.parked, true);
|
|
393
|
+
assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("an ancestor (after promotion): not parked, the label is removed, and the issue goes on as before", async () => {
|
|
397
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
398
|
+
const r = await gated(w, 41);
|
|
399
|
+
assert.ok(!r.parked); assert.equal(r.detail, "no already-fixed signal");
|
|
400
|
+
assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
|
|
401
|
+
const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
|
|
402
|
+
runOne: async () => ({ terminalReason: "merged", pr: 7, cost_usd: 1 }) });
|
|
403
|
+
assert.deepEqual(rows.map((x) => x.outcome), ["merged"]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
|
|
407
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 40: { comments: [] } } });
|
|
408
|
+
const r = await gated(w, 41);
|
|
409
|
+
assert.ok(!r.parked); assert.deepEqual(w.edits, []);
|
|
410
|
+
const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo",
|
|
411
|
+
io: world({ mergedPrs: parentPr, ancestry: { [MERGE]: 128 } }).io });
|
|
412
|
+
assert.equal(g.state, "unresolved");
|
|
413
|
+
assert.equal((await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", defaultBranch: async () => null, io: world().io })).state, "unresolved");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("the trusted trajectory 'merged at <sha>' is the fallback when no merged PR names the parent", async () => {
|
|
417
|
+
const w = world({ issues: { 40: { comments: [{ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:TRAJECTORY -->\n| Merge | merged at abcdef123456 |" },
|
|
418
|
+
{ author: { login: "mallory" }, authorAssociation: "NONE", body: "<!-- FORGE:TRAJECTORY --> merged at 0123456789ab" }] } }, ancestry: { abcdef123456: 1 } });
|
|
419
|
+
const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io });
|
|
420
|
+
assert.equal(g.state, "awaiting-promotion"); assert.deepEqual(g.shas, ["abcdef123456"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("mixed post-deploy + code items, and issues without DEFERRED_FROM, are unaffected (no gate calls)", async () => {
|
|
424
|
+
for (const body of [followup(40, [item("R2", "post-deploy"), item("R3", "defer")]), item("R2", "post-deploy")]) {
|
|
425
|
+
const w = world({ issues: { 41: { body } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
426
|
+
const r = await gated(w, 41);
|
|
427
|
+
assert.ok(!r.parked); assert.ok(!w.gitCalls.some((a) => a[0] === "merge-base"));
|
|
428
|
+
}
|
|
429
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } } });
|
|
430
|
+
assert.equal((await pf(w, 41)).detail, "labelled, left to the lane"); // no default branch: gate off, today's behaviour
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("post_deploy.precondition_cmd: exit 0 ready, exit 1 parked awaiting-precondition, other exits fail open", async () => {
|
|
434
|
+
const body = followup(40, [item("R2", "post-deploy")]);
|
|
435
|
+
for (const [code, state] of [[0, "ready"], [1, "awaiting-precondition"], [2, "unresolved"], [-1, "unresolved"]]) {
|
|
436
|
+
const w = world({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
|
|
437
|
+
const calls = [];
|
|
438
|
+
w.io.execCode = async (cmd, args, opts) => { calls.push([cmd, args, opts.env.FORGE_ISSUE]); return code; };
|
|
439
|
+
const g = await postDeployGate({ iss: { number: 41, body }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io, preconditionCmd: "check-rollout" });
|
|
440
|
+
assert.equal(g.state, state);
|
|
441
|
+
assert.deepEqual(calls, [["sh", ["-c", "check-rollout", "precondition", "41"], "41"]]);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("forgedock triage prints a parked issue, one awaiting-promotion line, and leaves it out of the lanes", async () => {
|
|
446
|
+
const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 13: { body: "y" } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
447
|
+
const logs = []; const l = console.log; console.log = (x) => logs.push(String(x));
|
|
448
|
+
let res;
|
|
449
|
+
try { res = await triageFromCli(["41", "13", "--repo", "o/r", "--lane", "staging", "--dry-run"], { leanIo: w.io }); } finally { console.log = l; }
|
|
450
|
+
assert.deepEqual(res.lanes, [13]);
|
|
451
|
+
assert.ok(logs.some((x) => /^#41 → parked \(awaiting-promotion: /.test(x)));
|
|
452
|
+
assert.ok(logs.includes("awaiting promotion: #41 (fixes on staging, not on main)"));
|
|
453
|
+
assert.deepEqual(w.edits, []);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("lazyDefaultBranch resolves GitHub's default and fetches it once, only when asked", async () => {
|
|
457
|
+
const w = world();
|
|
458
|
+
const d = lazyDefaultBranch({ io: w.io, repo: "o/r", cwd: "/repo" });
|
|
459
|
+
assert.equal(w.gitCalls.length, 0);
|
|
460
|
+
assert.equal(await d(), "main"); assert.equal(await d(), "main");
|
|
461
|
+
assert.deepEqual(w.gitCalls, [["fetch", "origin", "main"]]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("orchestrate: parked issues start no lane and the report has one awaiting-promotion line, no escalations", async () => {
|
|
465
|
+
const issues = {}; for (const n of [41, 42, 43]) issues[n] = { body: followup(40, [item("R2", "post-deploy")]) };
|
|
466
|
+
const w = world({ issues, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
|
|
467
|
+
const lanes = [];
|
|
468
|
+
const rows = await orchestrate({ issues: [41, 42, 43], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
|
|
469
|
+
resolveLane: async () => ({ ok: true, lane: "staging" }),
|
|
470
|
+
runOne: async (n) => { lanes.push(n); return { terminalReason: "merged", pr: 1, cost_usd: 1 }; } });
|
|
471
|
+
assert.deepEqual(lanes, []);
|
|
472
|
+
assert.deepEqual(rows.map((r) => [r.outcome, r.cost_usd]), [["awaiting-promotion", 0], ["awaiting-promotion", 0], ["awaiting-promotion", 0]]);
|
|
473
|
+
const lists = formatOutcomeLists(rows);
|
|
474
|
+
assert.deepEqual(lists, ["awaiting promotion: #41 #42 #43 (fixes on staging, not on main)"]);
|
|
475
|
+
assert.match(formatTable(rows), /#41\s+awaiting-promotion \(pre-filter\)/);
|
|
476
|
+
assert.equal(formatParkedLine([{ issue: 1, outcome: "merged" }]), "");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe("#4467: isTrustedIssueComment", () => {
|
|
481
|
+
it("trusts the App in gh issue view and REST shapes and trusted associations; not other contributors", () => {
|
|
482
|
+
assert.equal(isTrustedIssueComment({ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR" }), true);
|
|
483
|
+
assert.equal(isTrustedIssueComment({ user: { login: "rapiercraft-forgedock[bot]", type: "Bot" }, author_association: "NONE" }), true);
|
|
484
|
+
assert.equal(isTrustedIssueComment({ author: { login: "app/rapiercraft-forgedock" }, authorAssociation: "NONE" }), true);
|
|
485
|
+
assert.equal(isTrustedIssueComment({ author: { login: "owner" }, authorAssociation: "OWNER" }), true);
|
|
486
|
+
assert.equal(isTrustedIssueComment({ author: { login: "someone" }, authorAssociation: "CONTRIBUTOR" }), false);
|
|
487
|
+
assert.equal(isTrustedIssueComment(null), false);
|
|
488
|
+
});
|
|
489
|
+
});
|