forgedock 1.28.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +77 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +356 -0
- package/bin/lean/context.test.mjs +307 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +178 -37
- package/bin/lean/lean.test.mjs +441 -7
- package/bin/lean/orchestrate.mjs +112 -5
- package/bin/lean/prefilter.mjs +81 -16
- package/bin/lean/prefilter.test.mjs +91 -2
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +419 -0
- package/bin/lean/render.mjs +38 -5
- package/bin/lean/testmap.mjs +101 -0
- package/bin/lean/testmap.test.mjs +73 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +54 -7
- package/commands/review-pr-staging.md +6 -1
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +317 -0
- package/scripts/test-map.test.sh +353 -0
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +13 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -50,8 +50,23 @@
|
|
|
50
50
|
* With `remote.fallbackLocal`, a lane placed remote runs on the host path instead of waiting when `max` remote lanes run,
|
|
51
51
|
* and instead of stopping when the budget refuses. The final `{where, reason}` is kept on the run record and the row as
|
|
52
52
|
* `placement` and passed to `runOne` as `ctx.placement` (the lane records it in its FORGE:STATE).
|
|
53
|
+
*
|
|
54
|
+
* In-batch recovery (#4267), on by default: this module recovers only when `recovery: {maxAttempts}` is passed, and the
|
|
55
|
+
* CLI always passes it (forge.yaml `orchestration.recovery.max_attempts`, default 1; 0 disables): a row `classifyNeedsHuman` calls
|
|
56
|
+
* recoverable (needs-human `ci.red` / `base.conflict` / `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
57
|
+
* edit, `ci-pending` whose PR `conflicting(issue, pr)` says conflicts) is not final while its persisted `recoveries` stay
|
|
58
|
+
* under `maxAttempts`: it is requeued as a recovery lane (`ctx.recover = {code, attempt, final}`, `final` when attempt ==
|
|
59
|
+
* `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
|
|
60
|
+
* itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
|
|
61
|
+
* still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
|
|
62
|
+
* full persisted lane reason (#4315).
|
|
63
|
+
* A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
|
|
64
|
+
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
65
|
+
* fire-and-forget: the runner never waits on it.
|
|
53
66
|
*/
|
|
54
67
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
68
|
+
import { classifyNeedsHuman, recoveryCodeOf } from "../engine/outcome-codes.mjs";
|
|
69
|
+
import { recoveryEvidence } from "./render.mjs";
|
|
55
70
|
|
|
56
71
|
// A dependency that finished like this no longer blocks its dependents.
|
|
57
72
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -144,7 +159,8 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
144
159
|
*/
|
|
145
160
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
146
161
|
store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
147
|
-
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null
|
|
162
|
+
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
163
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null }) {
|
|
148
164
|
const saved = store?.load?.() ?? null;
|
|
149
165
|
const order = saved ? [...saved.order] : [];
|
|
150
166
|
const set = new Set(order);
|
|
@@ -193,13 +209,22 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
193
209
|
const sandboxUse = toMap(saved?.sandbox); // issue -> {minutes, cost_usd, attempts} (#4255)
|
|
194
210
|
const remoteCpm = Number(remote?.costPerMinute) > 0 ? Number(remote.costPerMinute) : 0;
|
|
195
211
|
let budgetStop = null, budgetHit = null; // this pass's refusal under on_budget stop; the last refusal reason
|
|
212
|
+
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
213
|
+
const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
|
|
214
|
+
const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
|
|
215
|
+
const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
|
|
216
|
+
// #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
|
|
217
|
+
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
218
|
+
const firstLine = (x) => String(x ?? "").split("\n")[0].slice(0, 300);
|
|
196
219
|
|
|
197
220
|
const save = () => {
|
|
198
221
|
if (!store) return;
|
|
199
222
|
store.save({ v: 1, order, deps: toObj(deps), lanes: toObj(lanes), crossSeen: [...crossSeen], results: toObj(results), running: toObj(runRecs),
|
|
200
223
|
pending, notBefore: toObj(notBefore), requeues: toObj(requeues), deferrals: toObj(deferrals), engineErrors: toObj(engineErrors),
|
|
201
224
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
202
|
-
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {})
|
|
225
|
+
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
226
|
+
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
227
|
+
...(relabelled.size ? { relabelled: [...relabelled] } : {}) });
|
|
203
228
|
};
|
|
204
229
|
const update = (row) => { store?.log?.(row); onUpdate(row); };
|
|
205
230
|
const classifyOne = async (n) => {
|
|
@@ -320,7 +345,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
320
345
|
|
|
321
346
|
// Apply one lane's result: requeue it (deferred, dependency-open, engine-error) or record its final row.
|
|
322
347
|
const settle = async (n, r, rec, pf) => {
|
|
323
|
-
|
|
348
|
+
// #4304: the pre-filter runs once per issue; a recovery pass's row adds the earlier attempt's cost, which already holds it.
|
|
349
|
+
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
350
|
+
const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
|
|
324
351
|
...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
325
352
|
...(rec?.placement ? { placement: rec.placement } : {}) };
|
|
326
353
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
@@ -341,6 +368,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
341
368
|
// Dependencies already done but still open on GitHub: a delay instead of a hot relaunch loop.
|
|
342
369
|
const allDone = depList.every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome));
|
|
343
370
|
requeue(n, allDone ? DEP_REQUEUE_DELAY_S * 1000 : 0); done(); save();
|
|
371
|
+
decided({ issue: n, what: `requeued behind ${depList.map((d) => `#${d}`).join(", ")} (Depends on edge added)`, why: firstLine(row.detail) || "the lane reported an open dependency",
|
|
372
|
+
alternative: "skip the issue and its dependents" });
|
|
344
373
|
update({ ...row, detail: `requeued behind ${depList.map((d) => `#${d}`).join(", ")}` });
|
|
345
374
|
return;
|
|
346
375
|
}
|
|
@@ -365,15 +394,57 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
365
394
|
if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
|
|
366
395
|
engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
|
|
367
396
|
requeue(n, 0); done(); save();
|
|
397
|
+
decided({ issue: n, what: "relaunch once after engine-error", why: firstLine(row.detail) || "engine-error", alternative: "end the issue engine-error and skip its dependents" });
|
|
368
398
|
update({ ...row, detail: `relaunching once: ${row.detail ?? ""}` });
|
|
369
399
|
return;
|
|
370
400
|
}
|
|
371
401
|
}
|
|
372
402
|
if (row.outcome === "decomposed" && subs.length) {
|
|
373
403
|
await addIssues(subs);
|
|
404
|
+
decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
|
|
405
|
+
alternative: "end the batch row decomposed and let dependents wait for a later batch" });
|
|
374
406
|
for (const [m, ds] of deps) if (ds.includes(n)) deps.set(m, [...new Set(ds.flatMap((d) => (d === n ? subs : [d])))]);
|
|
375
407
|
noteCrossLane();
|
|
376
408
|
}
|
|
409
|
+
// #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
|
|
410
|
+
const prior = recovering.get(n) ?? null;
|
|
411
|
+
const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail });
|
|
412
|
+
if (prior) {
|
|
413
|
+
recovering.delete(n);
|
|
414
|
+
// #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
|
|
415
|
+
row.cost_usd = Math.round(((Number(row.cost_usd) || 0) + (Number(prior.cost_usd) || 0)) * 1e6) / 1e6;
|
|
416
|
+
row.minutes = Math.round(((Number(row.minutes) || 0) + (Number(prior.minutes) || 0)) * 10) / 10;
|
|
417
|
+
if (row.outcome === "merged") row.recovered = prior.code;
|
|
418
|
+
else row.recovery = { code: prior.code, attempts: recoveries.get(n) ?? prior.attempt, outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), detail: firstLine(row.detail) };
|
|
419
|
+
// A recovery that failed for good keeps needs-human. The lane relabels needs-human/ci-pending itself; engine-error (after its
|
|
420
|
+
// relaunch) and deferred (no longer requeued) end here, so the runner does it, once. invalid/decomposed/dependency-*/merged never.
|
|
421
|
+
if (["engine-error", "deferred"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
422
|
+
try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (recoveryMax && (row.outcome === "needs-human" || row.outcome === "ci-pending")) {
|
|
426
|
+
let isConflicting = false;
|
|
427
|
+
if (row.outcome === "ci-pending" && typeof conflicting === "function" && Number.isInteger(row.pr)) {
|
|
428
|
+
try { isConflicting = (await conflicting(n, row.pr)) === true; } catch { isConflicting = false; }
|
|
429
|
+
}
|
|
430
|
+
const facts = { ...row, issueEdited: r?.issueEdited === true, conflicting: isConflicting };
|
|
431
|
+
const used = recoveries.get(n) ?? 0;
|
|
432
|
+
if (classifyNeedsHuman(facts) === "recoverable" && used < recoveryMax) {
|
|
433
|
+
const code = recoveryCodeOf(facts), attempt = used + 1;
|
|
434
|
+
// #4315: the full lane detail is kept (never firstLine) for the relabel when the batch stops before this attempt
|
|
435
|
+
recoveries.set(n, attempt); recovering.set(n, { code, attempt, cost_usd: Number(row.cost_usd) || 0, minutes: Number(row.minutes) || 0,
|
|
436
|
+
detail: String(row.detail ?? ""), outcome: row.outcome, ...(row.code ? { result_code: row.code } : {}), prev_attempt: prior?.attempt ?? 0 });
|
|
437
|
+
requeue(n, 0); done(); save();
|
|
438
|
+
decided({ issue: n, what: `recovery lane for ${code} (attempt ${attempt}/${recoveryMax}); dependents stay queued`, why: firstLine(row.detail) || code,
|
|
439
|
+
alternative: "leave needs-human for the operator and skip the dependents" });
|
|
440
|
+
update({ ...row, outcome: "recovering", detail: `recovering (${code}), attempt ${attempt}/${recoveryMax}: ${firstLine(row.detail)}` });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// #4302: a non-final recovery attempt left needs-human/ci-pending unlabelled for its successor; none is launched, so relabel once.
|
|
445
|
+
if (prior && prior.attempt < recoveryMax && ["needs-human", "ci-pending"].includes(row.outcome) && typeof relabelNeedsHuman === "function") {
|
|
446
|
+
try { await relabelNeedsHuman({ issue: n, evidence: evidence() }); } catch { /* best effort */ }
|
|
447
|
+
}
|
|
377
448
|
results.set(n, row);
|
|
378
449
|
if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
|
|
379
450
|
save(); update(row);
|
|
@@ -394,6 +465,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
394
465
|
return;
|
|
395
466
|
}
|
|
396
467
|
const lane = ln?.lane;
|
|
468
|
+
if (relabelled.delete(n)) save(); // #4315: the recovery lane launches after all; it removes needs-human itself
|
|
397
469
|
const pl = placed.get(n);
|
|
398
470
|
placed.delete(n);
|
|
399
471
|
const rec = { startedAt: now(), ...(lane ? { lane } : {}), ...(remoteRun.has(n) ? { remote: true } : {}), ...(pl ? { placement: pl } : {}) };
|
|
@@ -406,7 +478,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
406
478
|
if (pf?.closed) r = { terminalReason: "invalid", cost_usd: 0, detail: `pre-filter: ${pf.detail}` };
|
|
407
479
|
else {
|
|
408
480
|
try {
|
|
409
|
-
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), onStart: (info) => {
|
|
481
|
+
r = await runOne(n, lane, { remote: !!rec.remote, ...(rec.placement ? { placement: rec.placement } : {}), ...(recovering.has(n) ? { recover: { code: recovering.get(n).code, attempt: recovering.get(n).attempt, final: recovering.get(n).attempt >= recoveryMax } } : {}), onStart: (info) => {
|
|
410
482
|
Object.assign(rec, info ?? {}); save();
|
|
411
483
|
if (reg && slotted.has(n) && info?.pid) reg.setLanePid(keyOf(n), info.pid);
|
|
412
484
|
} });
|
|
@@ -517,8 +589,43 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
517
589
|
}
|
|
518
590
|
}
|
|
519
591
|
if (reg) withdrawExcept(new Set());
|
|
592
|
+
const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
593
|
+
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
594
|
+
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
595
|
+
for (const [n, q] of recovering) {
|
|
596
|
+
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
597
|
+
const cause = heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n);
|
|
598
|
+
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
599
|
+
queued: q.attempt, cause, recoveryMax });
|
|
600
|
+
try { await relabelNeedsHuman({ issue: n, evidence }); } catch { /* best effort */ }
|
|
601
|
+
relabelled.add(n);
|
|
602
|
+
}
|
|
520
603
|
save();
|
|
521
|
-
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail:
|
|
604
|
+
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
605
|
+
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
606
|
+
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Max chars of one row's detail in the batch-report outcome lists (#4298). */
|
|
610
|
+
export const OUTCOME_LINE_MAX = 200;
|
|
611
|
+
/**
|
|
612
|
+
* #4267 batch-report lines after the table: `recovered: #A (ci.red) #B (base.conflict)`, one `unrecovered:` line per
|
|
613
|
+
* mechanical needs-human row (failed recovery or a terminal code), then one escalation list holding only human codes,
|
|
614
|
+
* asked once at the end of the batch. Empty when there is nothing to list.
|
|
615
|
+
*/
|
|
616
|
+
export function formatOutcomeLists(rows) {
|
|
617
|
+
const out = [];
|
|
618
|
+
const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
|
|
619
|
+
const rec = (rows ?? []).filter((r) => r?.recovered);
|
|
620
|
+
if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
|
|
621
|
+
for (const r of rows ?? []) {
|
|
622
|
+
if (!r || r.recovered) continue;
|
|
623
|
+
const human = r.outcome === "needs-human" && classifyNeedsHuman(r) === "human";
|
|
624
|
+
if (!human && (r.outcome === "needs-human" || r.recovery)) out.push(`unrecovered: #${r.issue} (${r.code ?? r.recovery?.code ?? "unclassified"}: ${one(r.recovery ? `recovery ${r.recovery.code} ended ${r.recovery.outcome}: ${r.recovery.detail}` : r.detail)})`);
|
|
625
|
+
}
|
|
626
|
+
const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
|
|
627
|
+
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
628
|
+
return out;
|
|
522
629
|
}
|
|
523
630
|
|
|
524
631
|
export function formatTable(rows) {
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
* Already-fixed pre-filter (#4123): cheap checks before any lane starts, so an issue that a merged PR or a later green
|
|
5
5
|
* CI run already resolved does not cost a worktree, a lease and a builder session.
|
|
6
6
|
* 1. merged PRs (and lane commits) that claim to fix #N -> candidate
|
|
7
|
-
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link
|
|
7
|
+
* 2. auto-filed CI-failure issue whose workflow is green again on the failing branch -> closed with the run link,
|
|
8
|
+
* unless it is flaky (#4305): flaky wording or label, a reopen after an earlier pre-filter close, or a failed run
|
|
9
|
+
* of the workflow at the green run's SHA. Then the shortcut is skipped and the issue goes on as normal.
|
|
8
10
|
* 3. every repo path the issue names is gone from origin/<lane> -> candidate
|
|
9
11
|
* A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
|
|
10
12
|
* ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
|
|
11
13
|
* All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
|
|
12
14
|
*/
|
|
13
|
-
import { capComment, sanitizeMd } from "./render.mjs";
|
|
15
|
+
import { capComment, sanitizeMd, noComments } from "./render.mjs";
|
|
14
16
|
import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
|
|
17
|
+
import { isTrustedComment } from "../engine/phases.mjs";
|
|
18
|
+
import { APP_SLUG } from "../init-repo.mjs";
|
|
15
19
|
|
|
16
20
|
export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
|
|
17
21
|
export const PREFILTER_MARKER = "FORGE:PREFILTER";
|
|
@@ -35,17 +39,51 @@ export function ciFailureRun(iss) {
|
|
|
35
39
|
return m && CI_FAIL_RE.test(text) ? { repo: m[1], id: Number(m[2]) } : null;
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
// #4316: a bare "sometimes" is ordinary prose; only "fails sometimes" / "sometimes fails" says the failure is intermittent
|
|
43
|
+
const FLAKY_RE = /\b(?:intermittent\w*|flaky|flak(?:e|es|iness)|fail(?:s|ed|ing)?\s+sometimes|sometimes\s+fail(?:s|ed|ing)?|non-?deterministic\w*)\b/i;
|
|
44
|
+
const loginOf = (c) => String(c?.author?.login ?? c?.user?.login ?? "");
|
|
45
|
+
const trusted = (c) => {
|
|
46
|
+
const login = loginOf(c);
|
|
47
|
+
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
48
|
+
};
|
|
49
|
+
/** #4315: a login compared as the engine posts it: lowercased, without `app/` or a `[bot]` suffix (`gh issue view` drops both). */
|
|
50
|
+
const bareLogin = (l) => String(l ?? "").trim().toLowerCase().replace(/^app\//, "").replace(/\[bot\]$/, "");
|
|
51
|
+
const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PREFILTER_MARKER} -->`);
|
|
52
|
+
/**
|
|
53
|
+
* #4315: the logins the engine posts as: the ForgeDock App (`gh issue view` reports it as `rapiercraft-forgedock` with
|
|
54
|
+
* CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
|
|
55
|
+
*/
|
|
56
|
+
export async function ownLogins(gh) {
|
|
57
|
+
const out = new Set([bareLogin(APP_SLUG)]);
|
|
58
|
+
try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) out.add(me); } catch { /* installation token: the App slug only */ }
|
|
59
|
+
return [...out];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
|
|
63
|
+
* earlier pre-filter close by the pipeline (the issue is open again, so an operator reopened it). {reason, why} or null.
|
|
64
|
+
* #4315: a marker comment is the pipeline's own when it is trusted or written by one of `own` (bare logins).
|
|
65
|
+
*/
|
|
66
|
+
export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
|
|
67
|
+
if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
|
|
68
|
+
if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
|
|
69
|
+
const mine = new Set((own ?? []).map(bareLogin));
|
|
70
|
+
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
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/** 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);
|
|
75
|
+
|
|
38
76
|
const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
|
|
39
77
|
/** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
|
|
40
78
|
export function namedPaths(text) {
|
|
41
|
-
const s = sanitizeMd(text)
|
|
79
|
+
const s = noComments(sanitizeMd(text), " ").replace(/\b[a-z]+:\/\/\S+/gi, " ");
|
|
42
80
|
return [...new Set([...s.matchAll(PATH_RE)].map((m) => m[1].replace(/^\.\//, "")).filter((p) => !p.startsWith("/") && !p.startsWith("../")))];
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
/**
|
|
46
|
-
* @returns {Promise<{prs:object[], commits:object[], ci:?object, missingPaths:string[], namedPaths:string[]}>}
|
|
84
|
+
* @returns {Promise<{prs:object[], commits:object[], ci:?object, ciSkip?:{reason:string, why:string}, missingPaths:string[], namedPaths:string[]}>}
|
|
47
85
|
*/
|
|
48
|
-
export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
86
|
+
export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = null }) {
|
|
49
87
|
const n = iss.number;
|
|
50
88
|
const sig = { prs: [], commits: [], ci: null, missingPaths: [], namedPaths: [] };
|
|
51
89
|
try {
|
|
@@ -63,7 +101,19 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
63
101
|
const branch = failed.head_branch || lane;
|
|
64
102
|
const latest = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?branch=${encodeURIComponent(branch)}&status=completed&per_page=1`])))?.workflow_runs?.[0];
|
|
65
103
|
if (latest && latest.id !== failed.id && latest.conclusion === "success" && Date.parse(latest.created_at) > Date.parse(failed.created_at)) {
|
|
66
|
-
|
|
104
|
+
const sha = latest.head_sha ?? "";
|
|
105
|
+
// #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
|
|
106
|
+
// and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
|
|
107
|
+
const own = await ownFor(iss, typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(io.gh));
|
|
108
|
+
let skip = flakyReason(iss, own);
|
|
109
|
+
const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
|
|
110
|
+
if (!skip && sha && failed.head_sha === sha) skip = same;
|
|
111
|
+
if (!skip && sha) {
|
|
112
|
+
const atSha = JSON.parse(String(await io.gh(["api", `repos/${repo}/actions/workflows/${failed.workflow_id}/runs?head_sha=${sha}&status=completed&per_page=100`])))?.workflow_runs ?? [];
|
|
113
|
+
if (atSha.some((r) => r?.conclusion === "failure")) skip = same;
|
|
114
|
+
}
|
|
115
|
+
if (skip) sig.ciSkip = skip;
|
|
116
|
+
else sig.ci = { workflow: failed.name ?? latest.name ?? String(failed.workflow_id), branch, failedUrl: failed.html_url, greenUrl: latest.html_url, greenSha: sha };
|
|
67
117
|
}
|
|
68
118
|
} catch { /* run unreadable: no CI signal */ }
|
|
69
119
|
}
|
|
@@ -82,10 +132,10 @@ export async function collectSignals({ iss, repo, lane, io, cwd }) {
|
|
|
82
132
|
* Pre-filter one issue. Never throws.
|
|
83
133
|
* @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
|
|
84
134
|
*/
|
|
85
|
-
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5 }) {
|
|
135
|
+
export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
|
|
86
136
|
const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
|
|
87
137
|
try {
|
|
88
|
-
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels"])));
|
|
138
|
+
const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
|
|
89
139
|
if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
|
|
90
140
|
if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
|
|
91
141
|
if (io.projector) {
|
|
@@ -93,35 +143,48 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
93
143
|
try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
|
|
94
144
|
if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
|
|
95
145
|
}
|
|
96
|
-
const sig = await collectSignals({ iss, repo, lane, io, cwd });
|
|
146
|
+
const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
|
|
97
147
|
const close = async (kind, body, detail) => {
|
|
148
|
+
let note = "";
|
|
98
149
|
if (!dryRun) {
|
|
99
|
-
|
|
150
|
+
// #4315: close first: the marker is posted only on an issue that was closed, so it always means "reopened" later
|
|
100
151
|
await io.gh(["issue", "close", String(issue), "-R", repo, "--reason", "not planned"]);
|
|
152
|
+
// #4314: the issue IS closed from here on: the result is closed:true whatever the comment does, so no lane starts on it.
|
|
153
|
+
// The comment is retried once, then a marker-only comment (a second, tiny payload) keeps the #4305 reopen guard working.
|
|
154
|
+
const comment = (text) => io.gh(["issue", "comment", String(issue), "-R", repo, "--body", text]);
|
|
155
|
+
const full = capComment(`<!-- ${PREFILTER_MARKER} -->\n<!-- FORGE:VALIDATE -->\n${body}`);
|
|
156
|
+
const minimal = `<!-- ${PREFILTER_MARKER} -->\nClosed by the pre-filter (${kind}); the explanation comment could not be posted. Reopen if this is wrong.`;
|
|
157
|
+
let posted = false;
|
|
158
|
+
for (const text of [full, full, minimal]) {
|
|
159
|
+
try { await comment(text); posted = true; break; } catch { /* next attempt */ }
|
|
160
|
+
}
|
|
161
|
+
if (!posted) note = " (closed, but no marker comment could be posted: a reopen is not recognised)";
|
|
101
162
|
try { await io.transitionLabel?.(issue, "invalid"); } catch { /* labels are best effort */ }
|
|
102
163
|
}
|
|
103
|
-
return { ...out, closed: !dryRun, kind, detail };
|
|
164
|
+
return { ...out, closed: !dryRun, kind, detail: `${detail}${note}` };
|
|
104
165
|
};
|
|
105
166
|
|
|
167
|
+
// #4305: a skipped CI-green shortcut is named in every non-closing detail line that follows (#4316: with its reason).
|
|
168
|
+
const keep = (detail) => ({ ...out, detail: sig.ciSkip ? `skipped CI-green shortcut: ${sig.ciSkip.reason} (${sig.ciSkip.why}); ${detail}` : detail });
|
|
106
169
|
if (sig.ci) {
|
|
107
170
|
out.candidate = true;
|
|
108
171
|
const c = sig.ci;
|
|
109
172
|
return await close("ci-green", `## Pre-filter: CI failure no longer reproduces\n**Workflow:** ${c.workflow} on \`${c.branch}\`\n**Failing run:** ${c.failedUrl}\n**Latest run (green):** ${c.greenUrl}${c.greenSha ? ` at \`${c.greenSha.slice(0, 12)}\`` : ""}\n\nClosed without a lane. Reopen if the failure comes back.`,
|
|
110
173
|
`CI green again: ${c.greenUrl}`);
|
|
111
174
|
}
|
|
112
|
-
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return
|
|
175
|
+
if (!sig.prs.length && !sig.commits.length && !sig.missingPaths.length) return keep("no already-fixed signal");
|
|
113
176
|
out.candidate = true;
|
|
114
177
|
const why = [...sig.prs.map((p) => `PR #${p.number}`), ...sig.commits.map((c) => c.sha.slice(0, 12)), ...(sig.missingPaths.length ? ["named paths missing"] : [])].join(", ");
|
|
115
|
-
if (dryRun) return
|
|
178
|
+
if (dryRun) return keep(`candidate: ${why}`);
|
|
116
179
|
let r;
|
|
117
180
|
try {
|
|
118
181
|
r = await io.runner({ role: "prefilter", issue, cwd, model, resume: null, system: PREFILTER_SYSTEM, readOnly: true,
|
|
119
182
|
prompt: prefilterPrompt({ issue: iss, lane, signals: sig }), maxMs: Math.round(maxMinutes * 60000) });
|
|
120
|
-
} catch (e) { return
|
|
183
|
+
} catch (e) { return keep(`validation failed (${String(e?.message ?? e).slice(0, 120)}): lane`); }
|
|
121
184
|
out.cost_usd = Number.isFinite(r?.costUsd) ? r.costUsd : 0;
|
|
122
185
|
const v = parsePrefilterVerdict(r?.text);
|
|
123
186
|
out.verdict = v?.verdict ?? null;
|
|
124
|
-
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return
|
|
187
|
+
if (!v || v.verdict === "CONFIRMED" || !v.evidence.length) return keep(`candidate (${why}) ${v ? (v.verdict === "CONFIRMED" ? "confirmed" : "without evidence") : "unparseable verdict"}: lane`);
|
|
125
188
|
const ev = v.evidence.map((x) => `- ${sanitizeMd(x).replace(/\s+/g, " ").slice(0, 400)}`).join("\n");
|
|
126
189
|
return await close("validated", `## Pre-filter: ${v.verdict}\n**Signal:** ${why}\n**Summary:** ${sanitizeMd(v.summary).replace(/\s+/g, " ").slice(0, 600) || "not reported"}\n\n**Evidence:**\n${ev}\n\nValidated by a single read-only check (${model}), without a lane. Reopen if this is wrong.`,
|
|
127
190
|
`${v.verdict.toLowerCase()} (${why})`);
|
|
@@ -132,7 +195,9 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
132
195
|
|
|
133
196
|
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
134
197
|
export function makePrefilter(opts) {
|
|
135
|
-
|
|
198
|
+
let own = null; // #4315: one `gh api user` per batch, and only when a marker comment needs it
|
|
199
|
+
const selfLogins = opts.selfLogins ?? (() => (own ??= ownLogins(opts.io.gh)));
|
|
200
|
+
return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
|
|
136
201
|
}
|
|
137
202
|
|
|
138
203
|
/** One report line: how many the pre-filter checked, flagged and closed, what validation cost, and the spend avoided. */
|
|
@@ -2,7 +2,7 @@
|
|
|
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, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
7
|
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
@@ -12,7 +12,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
|
|
|
12
12
|
/** Fake GitHub + git for one repo `o/r`, lane `staging`. */
|
|
13
13
|
function world(o = {}) {
|
|
14
14
|
const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
|
|
15
|
-
issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {},
|
|
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
16
|
verdict: o.verdict ?? null };
|
|
17
17
|
w.io = {
|
|
18
18
|
gh: async (a) => {
|
|
@@ -25,6 +25,8 @@ function world(o = {}) {
|
|
|
25
25
|
if (m) return JSON.stringify(w.runs[m[1]]);
|
|
26
26
|
const wf = /actions\/workflows\/(\d+)\/runs\?branch=([^&]+)/.exec(a[1]);
|
|
27
27
|
if (wf) return JSON.stringify({ workflow_runs: w.workflowRuns[`${wf[1]}:${decodeURIComponent(wf[2])}`] ?? [] });
|
|
28
|
+
const at = /actions\/workflows\/\d+\/runs\?head_sha=([^&]+)/.exec(a[1]);
|
|
29
|
+
if (at) { w.shaQueries = (w.shaQueries ?? 0) + 1; return JSON.stringify({ workflow_runs: w.shaRuns[at[1]] ?? [] }); }
|
|
28
30
|
}
|
|
29
31
|
if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
|
|
30
32
|
if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
|
|
@@ -100,6 +102,90 @@ describe("pre-filter (#4123)", () => {
|
|
|
100
102
|
assert.deepEqual(w.closed, [[34, "not planned"]]);
|
|
101
103
|
});
|
|
102
104
|
|
|
105
|
+
it("#4305 the CI-green shortcut is skipped for intermittent, reopened and same-SHA-flaky CI failures", async () => {
|
|
106
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
107
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
108
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
109
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] }, ...extra });
|
|
110
|
+
const bot = { author: { login: "rapiercraft-forgedock[bot]" }, authorAssociation: "NONE", body: "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces" };
|
|
111
|
+
const stranger = { author: { login: "someone" }, authorAssociation: "NONE", body: bot.body };
|
|
112
|
+
const cases = [
|
|
113
|
+
["intermittent wording", green({ body: `\`a.test.sh\` fails intermittently on macOS.\n${link}` }), /issue says the failure is intermittent/, "flaky"],
|
|
114
|
+
["flaky label", green({ labels: [{ name: "flaky" }] }), /flaky label/, "flaky"],
|
|
115
|
+
["reopened after a pre-filter close", green({ comments: [bot] }), /reopened after a pre-filter close/, "reopened"],
|
|
116
|
+
["failed and passed at the same SHA", green({}, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 601, conclusion: "failure" }, { id: 600, conclusion: "success" }] } }), /failed and passed at 8d9f6ba0aaaa/, "same-sha"],
|
|
117
|
+
];
|
|
118
|
+
for (const [name, w, why, reason] of cases) {
|
|
119
|
+
const r = await pf(w, 34);
|
|
120
|
+
assert.equal(r.closed, false, name); assert.notEqual(r.kind, "ci-green", name);
|
|
121
|
+
assert.deepEqual(w.closed, [], name); assert.deepEqual(w.comments, [], name); assert.equal(w.runner.length, 0, name);
|
|
122
|
+
assert.ok(r.detail.startsWith(`skipped CI-green shortcut: ${reason} (`), `${name}: ${r.detail}`); assert.match(r.detail, why, name);
|
|
123
|
+
assert.match(r.detail, /no already-fixed signal$/, name);
|
|
124
|
+
}
|
|
125
|
+
// wording and the reopen marker need no runs lookup; an untrusted marker comment is no reopen, and a green-only SHA still closes
|
|
126
|
+
const quiet = green({ body: `fails intermittently ${link}` });
|
|
127
|
+
await pf(quiet, 34); assert.equal(quiet.shaQueries ?? 0, 0);
|
|
128
|
+
const forged = green({ comments: [stranger] }, { shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
129
|
+
const r = await pf(forged, 34);
|
|
130
|
+
assert.equal(r.kind, "ci-green"); assert.equal(forged.shaQueries, 1);
|
|
131
|
+
// an unreadable same-SHA lookup is no proof either: no shortcut
|
|
132
|
+
const down = green();
|
|
133
|
+
const gh0 = down.io.gh; down.io.gh = async (a) => (/head_sha=/.test(a[1] ?? "") ? (() => { throw new Error("HTTP 502"); })() : gh0(a));
|
|
134
|
+
assert.equal((await pf(down, 34)).closed, false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("#4316 a bare \"sometimes\" is prose, not flaky wording; \"fails sometimes\" and \"sometimes fails\" are", () => {
|
|
138
|
+
assert.equal(flakyReason({ title: "deploy check failed", body: "This sometimes happens after a deploy." }), null);
|
|
139
|
+
assert.equal(flakyReason({ title: "x", body: "Sometimes we rerun it by hand." }), null);
|
|
140
|
+
for (const body of ["the job fails sometimes on macOS", "it sometimes fails on macOS", "shell-compat failed sometimes", "intermittent timeout", "a flaky test"]) {
|
|
141
|
+
assert.equal(flakyReason({ title: "ci failed", body })?.reason, "flaky", body);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it("#4315 a comment shaped like #4235's real pre-filter comment is the pipeline's own: a reopened CI-failure issue is kept for a lane", async () => {
|
|
145
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
146
|
+
const green = (issue, extra = {}) => world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, ...issue } },
|
|
147
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
148
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] },
|
|
149
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] }, ...extra });
|
|
150
|
+
// the shape `gh issue view --json comments` returns for the ForgeDock App on #4235: no `[bot]`, CONTRIBUTOR, marker first
|
|
151
|
+
const body = "<!-- FORGE:PREFILTER -->\n<!-- FORGE:VALIDATE -->\n## Pre-filter: CI failure no longer reproduces\n**Workflow:** shell-compat on `staging`";
|
|
152
|
+
const real = { author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body };
|
|
153
|
+
const w = green({ comments: [real] });
|
|
154
|
+
const r = await pf(w, 34);
|
|
155
|
+
assert.equal(r.closed, false); assert.notEqual(r.kind, "ci-green");
|
|
156
|
+
assert.match(r.detail, /reopened after a pre-filter close/);
|
|
157
|
+
assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
|
|
158
|
+
// the token's own user (`gh api user`) counts too, cached once per makePrefilter batch; a CONTRIBUTOR stranger does not
|
|
159
|
+
const mine = green({ comments: [{ ...real, author: { login: "Op-User" } }] });
|
|
160
|
+
let userCalls = 0; const gh0 = mine.io.gh;
|
|
161
|
+
mine.io.gh = async (a) => (a[0] === "api" && a[1] === "user" ? (userCalls++, "op-user\n") : gh0(a));
|
|
162
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: mine.io });
|
|
163
|
+
assert.match((await run(34, "staging")).detail, /reopened after a pre-filter close/);
|
|
164
|
+
await run(34, "staging"); assert.equal(userCalls, 1);
|
|
165
|
+
const stranger = green({ comments: [{ ...real, author: { login: "someone" } }] });
|
|
166
|
+
assert.equal((await pf(stranger, 34)).kind, "ci-green");
|
|
167
|
+
// BUG-4: the marker is posted only after a successful close; a failed close posts no marker
|
|
168
|
+
const order = [];
|
|
169
|
+
const ok = green(); const g2 = ok.io.gh;
|
|
170
|
+
ok.io.gh = async (a) => { if (["issue close", "issue comment"].includes(a.slice(0, 2).join(" "))) order.push(a[1]); return g2(a); };
|
|
171
|
+
assert.equal((await pf(ok, 34)).closed, true); assert.deepEqual(order, ["close", "comment"]);
|
|
172
|
+
const fails = green(); const g3 = fails.io.gh;
|
|
173
|
+
fails.io.gh = async (a) => (a.slice(0, 2).join(" ") === "issue close" ? (() => { throw new Error("HTTP 403"); })() : g3(a));
|
|
174
|
+
const f = await pf(fails, 34);
|
|
175
|
+
assert.equal(f.closed, false); assert.deepEqual(fails.comments, []); assert.match(f.detail, /pre-filter error/);
|
|
176
|
+
// #4314: a failing comment after a successful close still counts as closed (no lane), via retry, then a marker-only comment
|
|
177
|
+
const flaky = (n, big) => { const x = green(); const g = x.io.gh; let calls = 0;
|
|
178
|
+
x.io.gh = async (a) => { if (a.slice(0, 2).join(" ") === "issue comment") { calls++; if (calls <= n || (big && a[a.indexOf("--body") + 1].includes("## Pre-filter"))) throw new Error("HTTP 502"); } return g(a); };
|
|
179
|
+
return x; };
|
|
180
|
+
const once = flaky(1); const r1 = await pf(once, 34);
|
|
181
|
+
assert.equal(r1.closed, true); assert.equal(once.comments.length, 1); assert.doesNotMatch(r1.detail, /no marker/);
|
|
182
|
+
const tiny = flaky(0, true); const r2 = await pf(tiny, 34);
|
|
183
|
+
assert.equal(r2.closed, true); assert.equal(tiny.comments.length, 1); assert.match(tiny.comments[0][1], /^<!-- FORGE:PREFILTER -->/);
|
|
184
|
+
assert.match(flakyReason({ comments: [{ body: tiny.comments[0][1], author: { login: "forgedock-app[bot]" }, authorAssociation: "NONE" }] }, ["forgedock-app"])?.reason ?? "", /reopened/);
|
|
185
|
+
const dead = flaky(99); const r3 = await pf(dead, 34);
|
|
186
|
+
assert.equal(r3.closed, true); assert.equal(dead.runner.length, 0); assert.deepEqual(dead.closed, [[34, "not planned"]]); assert.match(r3.detail, /no marker comment/);
|
|
187
|
+
});
|
|
188
|
+
|
|
103
189
|
it("a CI-failure issue whose workflow is still red (or whose latest run is the failing one) goes to a lane", async () => {
|
|
104
190
|
const base = { issues: { 34: { title: "ci failed", body: "https://github.com/o/r/actions/runs/500" } },
|
|
105
191
|
runs: { 500: { id: 500, workflow_id: 7, head_branch: "staging", created_at: "2026-10-10T00:00:00Z" } } };
|
|
@@ -112,6 +198,9 @@ describe("pre-filter (#4123)", () => {
|
|
|
112
198
|
|
|
113
199
|
it("every named path missing on the lane is a candidate; any surviving path is not", async () => {
|
|
114
200
|
assert.deepEqual(namedPaths("see `src/old/foo.js:12` and lib/bar.py, not https://x.io/a/b.js <!-- FORGE:STATE {\"a\":\"c/d.js\"} -->"), ["src/old/foo.js", "lib/bar.py"]);
|
|
201
|
+
// #4298: the scan-based noComments: a `--!>` closer ends a comment and an unclosed one runs to the end (the old
|
|
202
|
+
// one-pass regex kept both hidden paths).
|
|
203
|
+
assert.deepEqual(namedPaths("fix lib/a.js <!-- x/hidden.js --!> visible/e.js and <!-- unclosed c/d.js"), ["lib/a.js", "visible/e.js"]);
|
|
115
204
|
const gone = world({ issues: { 5: { body: "crash in src/old/foo.js:12" } }, verdict: { verdict: "INVALID", evidence: ["src/old/foo.js removed in abc123"] } });
|
|
116
205
|
assert.equal((await pf(gone, 5)).closed, true);
|
|
117
206
|
assert.match(gone.runner[0].prompt, /do not exist on origin\/staging: src\/old\/foo\.js/);
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -50,14 +50,17 @@ export function ciUnavailablePrompt() {
|
|
|
50
50
|
/** #4163: the extra host-rules line when the engine pinned the repo's toolchain on PATH; "" otherwise (prompt unchanged). */
|
|
51
51
|
const toolchainRule = (t) => (t ? `\nThe engine pinned this repo's toolchain on PATH (${t}). Use it as is: do not install global shims or other versions and do not edit PATH; if the pinned tool itself fails, report it as a toolchain/environment problem instead of working around it.` : "");
|
|
52
52
|
|
|
53
|
+
/** #4272: the extra line citing the repo-facts cache by path (never its content) when it exists; "" otherwise (prompt unchanged). */
|
|
54
|
+
const factsRule = (f) => (f ? `\nRepo facts (toolchain pins, slow suites, layer rules, hotspots) are cached in ${f}: read it before choosing checks or placing code.` : "");
|
|
55
|
+
|
|
53
56
|
/** #4221: the extra host-rules line when the lane worktree is sparse; "" otherwise (prompt unchanged). */
|
|
54
57
|
const sparseRule = (s) => (s?.excluded ? `\nThis worktree is a sparse checkout: ${s.excluded} tracked file(s) of ${s.maxKb} KB or more are not on disk (\`git ls-files -t | grep '^S'\` lists them). They are not deleted: never commit their removal. If you need one, run \`git sparse-checkout add /<path>\`.` : "");
|
|
55
58
|
|
|
56
59
|
/** Builder call 1: investigate and write the contract. No code edits. */
|
|
57
|
-
export function builderPlanPrompt(n, { toolchain = null, sparse = null } = {}) {
|
|
60
|
+
export function builderPlanPrompt(n, { toolchain = null, sparse = null, facts = null } = {}) {
|
|
58
61
|
return `Resolve issue #${n} (read it with gh issue view ${n} --comments). This first step is PLANNING ONLY: do not edit code, do not commit.
|
|
59
62
|
|
|
60
|
-
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}
|
|
63
|
+
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
61
64
|
|
|
62
65
|
1. Write ${INVESTIGATION_FILE} as markdown with exactly these headings, concise, file:line wherever you cite code (no HTML comments):
|
|
63
66
|
**Verdict**: CONFIRMED|ALREADY-FIXED|INVALID|BLOCKED
|
|
@@ -79,7 +82,7 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
|
|
|
79
82
|
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>"}],
|
|
80
83
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
81
84
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
82
|
-
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change>"}],
|
|
85
|
+
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
|
|
83
86
|
"decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
|
|
84
87
|
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
|
|
85
88
|
A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
|
|
@@ -88,11 +91,11 @@ ${BLOCK_RULES}`;
|
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/** Builder call 2, same session: implement the frozen contract. */
|
|
91
|
-
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null } = {}) {
|
|
94
|
+
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
|
|
92
95
|
const remote = mode === "ci" || mode === "hybrid";
|
|
93
96
|
return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
|
|
94
97
|
|
|
95
|
-
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
98
|
+
${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
|
|
96
99
|
|
|
97
100
|
The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
|
|
98
101
|
- {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
|
|
@@ -115,6 +118,15 @@ If you started Docker containers or volumes that are still there, list their nam
|
|
|
115
118
|
If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/**
|
|
122
|
+
* forge#4275: the compiled context pack for the plan prompt (context_pack: on); "" for an empty pack. Labelled as hints:
|
|
123
|
+
* the issue outranks the code, which outranks the pack, and contract quotes never come from it.
|
|
124
|
+
*/
|
|
125
|
+
export function contextPackNote(pack) {
|
|
126
|
+
if (!pack?.text) return "";
|
|
127
|
+
return `\n\nContext pack (hints, not proof; compiled by the engine from GitHub and git). The issue outranks the code, which outranks this pack: verify anything you rely on. Contract requirements quote only the issue and its trusted comments, never this pack. Open a link (gh issue view N / gh pr view N) yourself when you need more.\n${pack.text}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
|
|
119
131
|
export function reproPlanNote(on) {
|
|
120
132
|
if (!on) return "";
|
|
@@ -140,8 +152,11 @@ export function contractCorrectionPrompt(problems) {
|
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
/** The one bounded correction round, in the builder's own session. */
|
|
143
|
-
export function conformanceResumePrompt(failures, mode = "local") {
|
|
144
|
-
|
|
155
|
+
export function conformanceResumePrompt(failures, mode = "local", lane = "") {
|
|
156
|
+
const baseMoved = lane && failures.some((f) => f?.kind === "validate_failed")
|
|
157
|
+
? `\n\nIf a validate command fails only because origin/${lane} moved on after this branch started (the change itself is fine), bring the branch up to date with \`git merge --no-edit origin/${lane}\` (already fetched; never rebase or force), resolve any conflicts, then re-run every validate command and record the results.`
|
|
158
|
+
: "";
|
|
159
|
+
return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}${baseMoved}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
/** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
|