forgedock 1.29.1 → 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 +5 -2
- package/bin/engine/phases.mjs +20 -3
- 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 +453 -66
- package/bin/engine.mjs +50 -38
- 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 +356 -2
- 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 +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- 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 +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- 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 +799 -127
- package/bin/lean/lean.test.mjs +1749 -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 +341 -99
- 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 +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- 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 +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- 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 +77 -10
- package/bin/lean/verify.test.mjs +74 -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 +6 -2
- 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 +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- 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/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- 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/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
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";
|
|
11
|
+
import { readPrecheckBudget } from "../engine-cli.mjs";
|
|
12
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
13
|
+
|
|
14
|
+
/** A fake run: each call takes `cost[command]` ms of the shared clock (default 1000) and returns rc[command] (default 0). */
|
|
15
|
+
function harness({ cost = {}, rc = {} } = {}) {
|
|
16
|
+
const h = { t: 0, live: 0, peak: 0, calls: [] };
|
|
17
|
+
h.now = () => h.t;
|
|
18
|
+
h.run = async ({ command, timeoutMs }) => {
|
|
19
|
+
h.calls.push([command, timeoutMs]); h.peak = Math.max(h.peak, ++h.live);
|
|
20
|
+
await new Promise((r) => setImmediate(r));
|
|
21
|
+
const c = cost[command] ?? 1000;
|
|
22
|
+
h.t += Math.min(c, timeoutMs); h.live--;
|
|
23
|
+
return c > timeoutMs ? { rc: 124, output: "timed out" } : { rc: rc[command] ?? 0, output: `${command} out` };
|
|
24
|
+
};
|
|
25
|
+
return h;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("runChecks (#4331)", () => {
|
|
29
|
+
it("runs up to `parallelism` commands at once and returns results in input order with ms", async () => {
|
|
30
|
+
const h = harness();
|
|
31
|
+
const r = await runChecks({ commands: ["a", "b", "c", "d", "e"], parallelism: 3, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
|
|
32
|
+
assert.equal(h.peak, 3);
|
|
33
|
+
assert.deepEqual(r.results.map((x) => x.command), ["a", "b", "c", "d", "e"]);
|
|
34
|
+
assert.ok(r.results.every((x) => x.rc === 0 && Number.isFinite(x.ms) && x.ms >= 0));
|
|
35
|
+
assert.deepEqual(r.deferred, []);
|
|
36
|
+
assert.ok(r.ms > 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("parallelism 1 is serial; 0, NaN or missing fall back to 1", async () => {
|
|
40
|
+
for (const p of [1, 0, NaN, undefined, "x"]) {
|
|
41
|
+
const h = harness();
|
|
42
|
+
await runChecks({ commands: ["a", "b", "c"], parallelism: p, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
|
|
43
|
+
assert.equal(h.peak, 1, String(p));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("past the budget the remaining commands are deferred to CI, not failed; a running one gets what is left", async () => {
|
|
48
|
+
const h = harness();
|
|
49
|
+
const r = await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 1500, perCommandMs: 300000, now: h.now, run: h.run });
|
|
50
|
+
assert.deepEqual(h.calls, [["a", 1500], ["b", 500]]);
|
|
51
|
+
assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["a", 0]], "b was cut by the budget (rc 124): deferred, not a failure");
|
|
52
|
+
assert.deepEqual(r.deferred, ["b", "c"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
|
|
56
|
+
const h = harness({ cost: { slow: 5000 } });
|
|
57
|
+
const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
|
|
58
|
+
run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
|
|
59
|
+
assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
|
|
60
|
+
assert.match(r.results[1].output, /spawn failed/);
|
|
61
|
+
assert.deepEqual(r.deferred, []);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("no commands: nothing runs", async () => {
|
|
65
|
+
let n = 0;
|
|
66
|
+
const r = await runChecks({ commands: [], parallelism: 4, budgetMs: 1000, perCommandMs: 1000, now: () => 0, run: async () => { n++; return { rc: 0 }; } });
|
|
67
|
+
assert.deepEqual([n, r.results, r.deferred, r.ms], [0, [], [], 0]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
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
|
+
|
|
108
|
+
describe("formatPrecheckSummary (#4331)", () => {
|
|
109
|
+
it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
|
|
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");
|
|
111
|
+
assert.equal(formatPrecheckSummary([{ precheck_ms: 6000 }]), "precheck: 0.1m total over 1 lane");
|
|
112
|
+
assert.equal(formatPrecheckSummary([{ issue: 1 }]), null);
|
|
113
|
+
assert.equal(formatPrecheckSummary(undefined), null);
|
|
114
|
+
});
|
|
115
|
+
it("orchestrate rows carry the lane's precheckMs/precheckDeferred as precheck_ms/precheck_deferred for the report line", async () => {
|
|
116
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
|
|
117
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { precheckMs: 90000, precheckDeferred: 1 }, 2: { precheckMs: 30000, precheckDeferred: 0 } })[n] }) });
|
|
118
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
|
|
119
|
+
assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
|
|
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
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
|
|
130
|
+
const withYaml = (text, fn) => { const d = tmpDir("precheck-"); writeFileSync(join(d, "forge.yaml"), text); return fn(d); };
|
|
131
|
+
it("reads the nested block and the flat key; invalid or absent is undefined", () => {
|
|
132
|
+
assert.equal(withYaml("lean:\n precheck:\n max_minutes: 7\n", readPrecheckBudget), 7);
|
|
133
|
+
assert.equal(withYaml("lean:\n precheck_max_minutes: 15\n repro_test: off\n", readPrecheckBudget), 15);
|
|
134
|
+
for (const bad of ["0", "-1", "abc", "121"]) assert.equal(withYaml(`lean:\n precheck_max_minutes: ${bad}\n`, readPrecheckBudget), undefined, bad);
|
|
135
|
+
assert.equal(withYaml("verify:\n mode: local\n", readPrecheckBudget), undefined);
|
|
136
|
+
assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
|
|
137
|
+
});
|
|
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";
|
|
@@ -54,9 +59,20 @@ const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PRE
|
|
|
54
59
|
* CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
|
|
55
60
|
*/
|
|
56
61
|
export async function ownLogins(gh) {
|
|
62
|
+
return (await resolveOwnLogins(gh)).logins;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* #4318: {logins, resolved}. When `gh api user` fails (an App installation token), the App's own slug from `gh api app`
|
|
66
|
+
* (a differently named App); `resolved` is false when neither lookup answered, so the batch memo may ask once more.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveOwnLogins(gh) {
|
|
57
69
|
const out = new Set([bareLogin(APP_SLUG)]);
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
let resolved = false;
|
|
71
|
+
try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) { out.add(me); resolved = true; } } catch { /* installation token */ }
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
try { const slug = bareLogin(await gh(["api", "app", "--jq", ".slug"])); if (slug) { out.add(slug); resolved = true; } } catch { /* the App slug only */ }
|
|
74
|
+
}
|
|
75
|
+
return { logins: [...out], resolved };
|
|
60
76
|
}
|
|
61
77
|
/**
|
|
62
78
|
* #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
|
|
@@ -67,11 +83,15 @@ export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
|
67
83
|
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
68
84
|
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
69
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" };
|
|
70
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" };
|
|
71
90
|
return null;
|
|
72
91
|
}
|
|
73
92
|
/** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
|
|
74
|
-
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));
|
|
75
95
|
|
|
76
96
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
77
97
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
@@ -104,7 +124,7 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
104
124
|
const sha = latest.head_sha ?? "";
|
|
105
125
|
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
106
126
|
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
107
|
-
const own = await ownFor(iss,
|
|
127
|
+
const own = await ownFor(iss, selfResolver(selfLogins, io.gh));
|
|
108
128
|
let skip = flakyReason(iss, own);
|
|
109
129
|
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
110
130
|
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
@@ -128,21 +148,166 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
128
148
|
return sig;
|
|
129
149
|
}
|
|
130
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
|
+
|
|
131
275
|
/**
|
|
132
276
|
* Pre-filter one issue. Never throws.
|
|
133
277
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
134
278
|
*/
|
|
135
|
-
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 }) {
|
|
136
280
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
137
281
|
try {
|
|
138
282
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
139
283
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
140
|
-
|
|
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" };
|
|
141
289
|
if (io.projector) {
|
|
142
290
|
let st = null;
|
|
143
291
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
144
292
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
145
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
|
+
}
|
|
146
311
|
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
147
312
|
const close = async (kind, body, detail) => {
|
|
148
313
|
let note = "";
|
|
@@ -195,8 +360,14 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
195
360
|
|
|
196
361
|
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
197
362
|
export function makePrefilter(opts) {
|
|
198
|
-
|
|
199
|
-
|
|
363
|
+
// #4315: one identity lookup per batch, and only when a marker comment needs it; #4318: an unresolved one is retried once
|
|
364
|
+
let own = null, tries = 0;
|
|
365
|
+
const selfLogins = opts.selfLogins ?? (() => (own ??= (async () => {
|
|
366
|
+
tries++;
|
|
367
|
+
const r = await resolveOwnLogins(opts.io.gh);
|
|
368
|
+
if (!r.resolved && tries < 2) own = null;
|
|
369
|
+
return r.logins;
|
|
370
|
+
})()));
|
|
200
371
|
return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
|
|
201
372
|
}
|
|
202
373
|
|