forgedock 1.30.0 → 1.32.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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -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 +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- 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 +104 -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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- 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 +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -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/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,17 @@ 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("#4451: orchestrate rows carry the lane's wall_ms and model_ms, so wall vs model time can be measured", async () => {
|
|
122
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "",
|
|
123
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { wall_ms: 90000, model_ms: 60000 } })[n] }) });
|
|
124
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.wall_ms, r.model_ms]), [[1, 90000, 60000], [2, undefined, undefined]]);
|
|
125
|
+
});
|
|
126
|
+
it("#4362: orchestrate rows carry the lane's firstPass as first_pass for the batch first-pass line", async () => {
|
|
127
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
|
|
128
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { firstPass: true }, 2: { firstPass: false } })[n] }) });
|
|
129
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.first_pass]), [[1, true], [2, false], [3, undefined]]);
|
|
130
|
+
assert.equal(formatFirstPassSummary(rows), "first-pass: 50% (1/2 lanes that reached the build)");
|
|
131
|
+
});
|
|
82
132
|
});
|
|
83
133
|
|
|
84
134
|
describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
|
|
@@ -91,3 +141,70 @@ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", ()
|
|
|
91
141
|
assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
|
|
92
142
|
});
|
|
93
143
|
});
|
|
144
|
+
|
|
145
|
+
describe("forgedock check core (#4364)", () => {
|
|
146
|
+
const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
|
|
147
|
+
|
|
148
|
+
it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
|
|
149
|
+
const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
|
|
150
|
+
assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
|
|
151
|
+
assert.equal(p.selected, 1); assert.equal(p.budgetMs, 120000); assert.equal(p.perCommandMs, 30000);
|
|
152
|
+
assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 2, verify: { precheckTotalSeconds: 999 } }).budgetMs, 120000);
|
|
153
|
+
assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 3, verify: {} }).budgetMs, 180000);
|
|
154
|
+
assert.deepEqual(planPrecheck({ learned: ["x"], verify: {} }), { commands: ["x"], added: 0, dropped: [], source: "learned", selected: 0, budgetMs: 600000, perCommandMs: 300000 });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("selfCheck runs validates then pre-PR commands; ok only when every run exits 0; ci mode leaves validates to CI", async () => {
|
|
158
|
+
const h = harness({ rc: { "make lint": 2 } });
|
|
159
|
+
const contract = { validate: [{ id: "V1", command: "node --test a" }, { id: "V2", command: " " }] };
|
|
160
|
+
const plan = { mode: "local", commands: ["npm test", "make lint"], budgetMs: 600000, perCommandMs: 300000 };
|
|
161
|
+
const r = await selfCheck({ contract, plan, now: h.now, run: h.run });
|
|
162
|
+
assert.deepEqual(h.calls.map((c) => c[0]), ["node --test a", "npm test", "make lint"]);
|
|
163
|
+
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);
|
|
164
|
+
const h2 = harness();
|
|
165
|
+
const r2 = await selfCheck({ contract, plan: { ...plan, mode: "ci" }, now: h2.now, run: h2.run });
|
|
166
|
+
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);
|
|
167
|
+
const h3 = harness({ cost: { "npm test": 5000 } });
|
|
168
|
+
const r3 = await selfCheck({ contract: {}, plan: { commands: ["npm test", "make lint"], budgetMs: 4000, perCommandMs: 300000 }, now: h3.now, run: h3.run });
|
|
169
|
+
assert.deepEqual(r3.deferred, ["npm test", "make lint"]); assert.equal(r3.ok, true, "deferred to CI is not a failure, as in the engine");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("selfCheckState and formatCheckTable", () => {
|
|
173
|
+
assert.equal(selfCheckState(null, "f"), "skipped"); assert.equal(selfCheckState({ fingerprint: "g", ok: true }, "f"), "stale");
|
|
174
|
+
assert.equal(selfCheckState({ fingerprint: "f", ok: false }, "f"), "red"); assert.equal(selfCheckState({ fingerprint: "f", ok: true }, "f"), "green");
|
|
175
|
+
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);
|
|
176
|
+
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);
|
|
177
|
+
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\)$/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("#4390 quietFailure: last 40 lines, failing ids across ecosystems, log pointer; short output unchanged", () => {
|
|
181
|
+
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)",
|
|
182
|
+
...Array.from({ length: 100 }, (_, i) => `line ${i}`)].join("\n");
|
|
183
|
+
const q = quietFailure(out, { logPath: ".forge-lean/logs/check-V1.log" });
|
|
184
|
+
assert.match(q, /^…line 60\n/); assert.match(q, /line 99\nfailing tests:\n/);
|
|
185
|
+
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);
|
|
186
|
+
assert.match(q, /\nfull log: \.forge-lean\/logs\/check-V1\.log$/);
|
|
187
|
+
assert.equal(quietFailure("ok\nall good\n"), "ok\nall good");
|
|
188
|
+
assert.equal(quietFailure(""), "(no output)");
|
|
189
|
+
assert.equal(quietFailure("x".repeat(50), { maxChars: 10 }), `…${"x".repeat(10)}`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("worktreeFingerprint: the same content committed or not is the same tree; an edit or new file changes it; .forge-lean is ignored", async () => {
|
|
193
|
+
const dir = tmpDir("selfcheck-fp-");
|
|
194
|
+
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" };
|
|
195
|
+
const g = (a) => execFileSync("git", a, { cwd: dir, encoding: "utf8", env });
|
|
196
|
+
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"]);
|
|
197
|
+
g(["branch", "base"]);
|
|
198
|
+
const fp = () => worktreeFingerprint({ git: async (a) => g(a), base: "base" });
|
|
199
|
+
const clean = await fp();
|
|
200
|
+
writeFileSync(join(dir, "a.js"), "2\n"); writeFileSync(join(dir, "new.js"), "n\n"); g(["rm", "-q", "gone.js"]);
|
|
201
|
+
const dirty = await fp(); assert.notEqual(dirty, clean);
|
|
202
|
+
execFileSync("mkdir", ["-p", join(dir, ".forge-lean")]); writeFileSync(join(dir, ".forge-lean", "result.json"), "{}");
|
|
203
|
+
g(["add", "-A"]); g(["reset", "-q", "--", ".forge-lean"]);
|
|
204
|
+
writeFileSync(join(dir, ".git", "info", "exclude"), ".forge-lean/\n");
|
|
205
|
+
g(["commit", "-q", "-m", "work"]);
|
|
206
|
+
assert.equal(await fp(), dirty, "committing the checked tree does not make the check stale");
|
|
207
|
+
writeFileSync(join(dir, "a.js"), "3\n");
|
|
208
|
+
assert.notEqual(await fp(), dirty);
|
|
209
|
+
});
|
|
210
|
+
});
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -10,12 +10,19 @@
|
|
|
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. With `post_deploy.min_age` (#4425) it also
|
|
16
|
+
* stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
|
|
17
|
+
* the default branch. Anything unresolvable fails open.
|
|
18
|
+
* All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
|
|
14
19
|
*/
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
15
21
|
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
16
22
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
17
23
|
import { isTrustedComment } from "../engine/phases.mjs";
|
|
18
24
|
import { APP_SLUG } from "../init-repo.mjs";
|
|
25
|
+
import { probeResultOf } from "./probe.mjs";
|
|
19
26
|
|
|
20
27
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
21
28
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -78,11 +85,15 @@ export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
|
78
85
|
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
79
86
|
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
80
87
|
const mine = new Set((own ?? []).map(bareLogin));
|
|
88
|
+
// #4399: the latest pipeline FORGE:PROBE result is a fail: the fix did not hold in production, so no already-fixed close
|
|
89
|
+
const probe = (iss.comments ?? []).filter((c) => probeResultOf(c?.body) && (trusted(c) || mine.has(bareLogin(loginOf(c))))).at(-1);
|
|
90
|
+
if (probe && probeResultOf(probe.body).result === "fail") return { reason: "probe-failed", why: "acceptance probe failed after promotion" };
|
|
81
91
|
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
92
|
return null;
|
|
83
93
|
}
|
|
84
94
|
/** 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);
|
|
95
|
+
const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => (isMarked(c) || probeResultOf(c?.body)) && !trusted(c)) ? resolve() : undefined);
|
|
96
|
+
const selfResolver = (selfLogins, gh) => (typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(gh));
|
|
86
97
|
|
|
87
98
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
88
99
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
@@ -115,7 +126,7 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
115
126
|
const sha = latest.head_sha ?? "";
|
|
116
127
|
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
117
128
|
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
118
|
-
const own = await ownFor(iss,
|
|
129
|
+
const own = await ownFor(iss, selfResolver(selfLogins, io.gh));
|
|
119
130
|
let skip = flakyReason(iss, own);
|
|
120
131
|
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
121
132
|
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
@@ -139,21 +150,225 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
|
|
|
139
150
|
return sig;
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
// #4389: post-deploy precondition gate. A follow-up filed by scripts/file-deferred-followups.sh whose items are all
|
|
154
|
+
// post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
|
|
155
|
+
export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
|
|
156
|
+
export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
|
|
157
|
+
const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
|
|
158
|
+
/** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
|
|
159
|
+
export function deferredFrom(body) {
|
|
160
|
+
const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
|
|
161
|
+
return m ? Number(m[1]) : null;
|
|
162
|
+
}
|
|
163
|
+
const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
|
|
164
|
+
/** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
|
|
165
|
+
export function postDeployOnly(body) {
|
|
166
|
+
const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
|
|
167
|
+
return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
|
|
168
|
+
}
|
|
169
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
170
|
+
/**
|
|
171
|
+
* #4467: an issue comment the engine may trust, in REST shape (`user.type`) or `gh issue view` shape (`author.login`,
|
|
172
|
+
* `authorAssociation`): what isTrustedComment trusts, or the ForgeDock App, which `gh issue view` reports as a bare
|
|
173
|
+
* `rapiercraft-forgedock` CONTRIBUTOR with no type and no `[bot]` suffix (#4315).
|
|
174
|
+
*/
|
|
175
|
+
export const isTrustedIssueComment = (c) => Boolean(c) && (trusted(c) || bareLogin(loginOf(c)) === bareLogin(APP_SLUG));
|
|
176
|
+
/** Merge commits of the merged PRs that claim to fix #parent; else the `merged at <sha>` of its trusted FORGE:TRAJECTORY. */
|
|
177
|
+
export async function parentMergeShas({ parent, repo, io }) {
|
|
178
|
+
const shas = new Set();
|
|
179
|
+
try {
|
|
180
|
+
const prs = JSON.parse(String(await io.gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${parent}`, "--json", "number,title,body,mergeCommit", "--limit", "20"])) || "[]");
|
|
181
|
+
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);
|
|
182
|
+
} catch { /* unreadable: try the trajectory */ }
|
|
183
|
+
if (!shas.size) {
|
|
184
|
+
try {
|
|
185
|
+
const v = JSON.parse(String(await io.gh(["issue", "view", String(parent), "-R", repo, "--json", "comments"])));
|
|
186
|
+
for (const c of v.comments ?? []) {
|
|
187
|
+
const b = String(c?.body ?? "");
|
|
188
|
+
const m = b.includes("FORGE:TRAJECTORY") && isTrustedIssueComment(c) ? /merged at `?([0-9a-f]{7,40})\b/i.exec(b) : null;
|
|
189
|
+
if (m) shas.add(m[1]);
|
|
190
|
+
}
|
|
191
|
+
} catch { /* unreadable: no SHA */ }
|
|
192
|
+
}
|
|
193
|
+
return [...shas];
|
|
194
|
+
}
|
|
195
|
+
/** Default `io.execCode`: the exit code of an argv-only child (-1 when it could not run or timed out). */
|
|
196
|
+
function execCode(cmd, args, opts) {
|
|
197
|
+
return new Promise((res) => {
|
|
198
|
+
try { execFile(cmd, args, opts, (err) => res(err ? (typeof err.code === "number" ? err.code : -1) : 0)); } catch { res(-1); }
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// #4425: post_deploy.min_age soak window. Deterministic: a strict duration parse, and git for the landing time.
|
|
202
|
+
const UNIT_MS = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 };
|
|
203
|
+
/** forge.yaml `post_deploy.min_age` ("30m", "24h", "7d", "2w"; a bare number is minutes, so "0" is allowed) -> ms, or null. */
|
|
204
|
+
export function parseMinAge(s) {
|
|
205
|
+
if (s == null) return null;
|
|
206
|
+
const m = /^(\d+(?:\.\d+)?)\s*([mhdw]?)$/i.exec(String(s).trim());
|
|
207
|
+
return m ? Math.round(Number(m[1]) * UNIT_MS[(m[2] || "m").toLowerCase()]) : null;
|
|
208
|
+
}
|
|
209
|
+
const WINDOW_RE = /\b(\d+(?:\.\d+)?)\s*(h|hrs?|hours?|d|days?|w|wks?|weeks?)\b/gi;
|
|
210
|
+
const POST_DEPLOY_ITEM_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:(.*)$/;
|
|
211
|
+
/** Per post-deploy checklist item: the largest duration its why states (ms), or null when it states none. */
|
|
212
|
+
export function itemWindowsMs(body) {
|
|
213
|
+
return noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null)
|
|
214
|
+
.map((t) => POST_DEPLOY_ITEM_RE.exec(t)).filter(Boolean).map((m) => {
|
|
215
|
+
const ms = [...m[1].matchAll(WINDOW_RE)].map((w) => Number(w[1]) * UNIT_MS[w[2][0].toLowerCase()]);
|
|
216
|
+
return ms.length ? Math.round(Math.max(...ms)) : null;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Unix seconds when `sha` reached origin/<def>: the oldest first-parent commit of <def> that descends from it (the merge
|
|
221
|
+
* that brought it in), or the SHA's own commit time when it is on the mainline itself. Null on any git error.
|
|
222
|
+
*/
|
|
223
|
+
export async function landedAt({ sha, def, io, cwd }) {
|
|
224
|
+
try {
|
|
225
|
+
// --first-parent with --ancestry-path drops merges that brought the SHA in through a second parent (git 2.55): two walks
|
|
226
|
+
const range = `${sha}..origin/${def}`;
|
|
227
|
+
const below = new Set(String(await io.git(["rev-list", "--ancestry-path", range], { cwd })).trim().split(/\s+/).filter(Boolean));
|
|
228
|
+
const chain = String(await io.git(["log", "--first-parent", "--format=%H %P %ct", range], { cwd })).trim().split("\n").map((l) => l.trim().split(/\s+/));
|
|
229
|
+
const oldest = chain.filter((c) => below.has(c[0])).at(-1);
|
|
230
|
+
let own = !oldest;
|
|
231
|
+
if (!own) {
|
|
232
|
+
const full = /^[0-9a-f]{40}$/i.test(sha) ? sha : String(await io.git(["rev-parse", `${sha}^{commit}`], { cwd })).trim();
|
|
233
|
+
own = oldest[1]?.toLowerCase() === full.toLowerCase();
|
|
234
|
+
}
|
|
235
|
+
const ct = own ? String(await io.git(["log", "-1", "--format=%ct", sha], { cwd })).trim() : oldest.at(-1);
|
|
236
|
+
return /^\d+$/.test(ct) ? Number(ct) : null;
|
|
237
|
+
} catch { return null; }
|
|
238
|
+
}
|
|
239
|
+
const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms % UNIT_MS.h === 0 && ms ? `${ms / UNIT_MS.h}h` : `${Math.round(ms / UNIT_MS.m)}m`);
|
|
240
|
+
/**
|
|
241
|
+
* #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
|
|
242
|
+
* #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
|
|
243
|
+
* @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
|
|
244
|
+
* Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
|
|
245
|
+
*/
|
|
246
|
+
export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
|
|
247
|
+
const parent = deferredFrom(iss?.body);
|
|
248
|
+
const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
|
|
249
|
+
if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
|
|
250
|
+
try {
|
|
251
|
+
const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
|
|
252
|
+
if (!def) return res("unresolved", "default branch unknown");
|
|
253
|
+
const shas = await parentMergeShas({ parent, repo, io });
|
|
254
|
+
if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
|
|
255
|
+
const off = [];
|
|
256
|
+
for (const sha of shas) {
|
|
257
|
+
let on;
|
|
258
|
+
try { await io.git(["merge-base", "--is-ancestor", sha, `origin/${def}`], { cwd }); on = true; } catch (e) { on = e?.code === 1 ? false : null; }
|
|
259
|
+
if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
|
|
260
|
+
if (!on) off.push(sha);
|
|
261
|
+
}
|
|
262
|
+
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 });
|
|
263
|
+
if (minAge != null) {
|
|
264
|
+
const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
|
|
265
|
+
let landed = 0;
|
|
266
|
+
for (const sha of shas) {
|
|
267
|
+
const t = await landedAt({ sha, def, io, cwd });
|
|
268
|
+
if (t == null) return res("unresolved", `landing time of ${sha.slice(0, 12)} on ${def} unknown`, { shas, default: def });
|
|
269
|
+
landed = Math.max(landed, t);
|
|
270
|
+
}
|
|
271
|
+
const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
|
|
272
|
+
if (at < until) {
|
|
273
|
+
const eligibleAt = new Date(until).toISOString();
|
|
274
|
+
return res("awaiting-min-age", `fix of #${parent} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (preconditionCmd) {
|
|
278
|
+
const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
|
|
279
|
+
{ cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
|
|
280
|
+
if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
|
|
281
|
+
if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
|
|
282
|
+
}
|
|
283
|
+
return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
|
|
284
|
+
} catch (e) {
|
|
285
|
+
return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* #4389: the default branch for the gate, resolved and fetched once, only when a gated issue asks: forge.yaml
|
|
290
|
+
* `branches.default`, else GitHub's default branch. Null when neither answers (the gate then fails open).
|
|
291
|
+
*/
|
|
292
|
+
export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
|
|
293
|
+
let p = null;
|
|
294
|
+
return () => (p ??= (async () => {
|
|
295
|
+
let name = configured;
|
|
296
|
+
if (!name) { try { name = String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])).trim() || null; } catch { name = null; } }
|
|
297
|
+
if (name) { try { await io.git(["fetch", "origin", name], { cwd }); } catch { /* offline: use the local ref */ } }
|
|
298
|
+
return name;
|
|
299
|
+
})());
|
|
300
|
+
}
|
|
301
|
+
/** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
|
|
302
|
+
async function markParked({ issue, repo, io, had, gate }) {
|
|
303
|
+
const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
|
|
304
|
+
try { await edit(); } catch {
|
|
305
|
+
try {
|
|
306
|
+
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."]);
|
|
307
|
+
await edit();
|
|
308
|
+
} catch { /* the comment and the report still show it */ }
|
|
309
|
+
}
|
|
310
|
+
if (had) return;
|
|
311
|
+
const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
|
|
312
|
+
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.`;
|
|
313
|
+
try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting min age:` / `awaiting precondition:` line) for the parked rows, or "". */
|
|
317
|
+
export function formatParkedLine(rows) {
|
|
318
|
+
const parked = (rows ?? []).filter((r) => r?.prefilter?.parked);
|
|
319
|
+
const uniq = (xs) => [...new Set(xs.filter(Boolean))];
|
|
320
|
+
const out = [];
|
|
321
|
+
const promo = parked.filter((r) => r.prefilter.kind === "awaiting-promotion");
|
|
322
|
+
if (promo.length) {
|
|
323
|
+
const lanes = uniq(promo.map((r) => r.prefilter.lane)), defs = uniq(promo.map((r) => r.prefilter.default));
|
|
324
|
+
out.push(`awaiting promotion: ${promo.map((r) => `#${r.issue}`).join(" ")} (fixes on ${lanes.join(", ") || "the lane"}, not on ${defs.join(", ") || "the default branch"})`);
|
|
325
|
+
}
|
|
326
|
+
const soak = parked.filter((r) => r.prefilter.kind === "awaiting-min-age"); // #4425
|
|
327
|
+
if (soak.length) {
|
|
328
|
+
const defs = uniq(soak.map((r) => r.prefilter.default));
|
|
329
|
+
out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
|
|
330
|
+
}
|
|
331
|
+
const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
|
|
332
|
+
if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
|
|
333
|
+
return out.join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
142
336
|
/**
|
|
143
337
|
* Pre-filter one issue. Never throws.
|
|
144
338
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
145
339
|
*/
|
|
146
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
|
|
340
|
+
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, minAge = null }) {
|
|
147
341
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
148
342
|
try {
|
|
149
343
|
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
150
344
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
151
|
-
|
|
345
|
+
const names = (iss.labels ?? []).map((l) => l?.name ?? l);
|
|
346
|
+
const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
|
|
347
|
+
// #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
|
|
348
|
+
const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
|
|
349
|
+
if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
|
|
152
350
|
if (io.projector) {
|
|
153
351
|
let st = null;
|
|
154
352
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
155
353
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
156
354
|
}
|
|
355
|
+
if (gated) {
|
|
356
|
+
const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
|
|
357
|
+
const had = names.includes(AWAITING_PROMOTION_LABEL);
|
|
358
|
+
if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
|
|
359
|
+
if (!dryRun) await markParked({ issue, repo, io, had, gate });
|
|
360
|
+
return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
|
|
361
|
+
}
|
|
362
|
+
if (gate.state === "ready" && had && !dryRun) {
|
|
363
|
+
try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
|
|
364
|
+
}
|
|
365
|
+
if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
|
|
366
|
+
}
|
|
367
|
+
// #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
|
|
368
|
+
if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
|
|
369
|
+
&& flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
|
|
370
|
+
return { ...out, detail: "acceptance probe failed after promotion (FORGE:PROBE): no already-fixed close; lane" };
|
|
371
|
+
}
|
|
157
372
|
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
158
373
|
const close = async (kind, body, detail) => {
|
|
159
374
|
let note = "";
|