forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* issue while maintenance starts stay within `share` of all lane starts (`dispatch: {started, maint}`, persisted), then
|
|
38
38
|
* P2+ features, then (`whenIdle`) maintenance once no feature is pending. Maintenance the budget never admits ends `held`.
|
|
39
39
|
*
|
|
40
|
+
* Milestone lifecycle (#4657), off unless `lifecycle` is passed (`bin/lean/lifecycle.mjs` makeLifecycle): at batch end
|
|
41
|
+
* `lifecycle.onBatchEnd(titles)` advances/closes each milestone a `merged` row's result named. Errors never change an outcome.
|
|
40
42
|
* Milestone drift (#4161), off unless `drift` is passed (`bin/lean/drift.mjs` makeDrift): a `merged` milestone-lane run
|
|
41
43
|
* whose result carries `structure` awaits `drift.onMerged(issue, row, result)` before its slot frees. A drift error is
|
|
42
44
|
* reported as a `drift` row and never changes the outcome. With `drift.holds(issue, {body, lane})` (block_dispatch), a
|
|
@@ -100,10 +102,12 @@
|
|
|
100
102
|
*/
|
|
101
103
|
import { parseDependsOn } from "../engine/phases.mjs";
|
|
102
104
|
import { classifyNeedsHuman, recoveryCodeOf, OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
105
|
+
import { detectGateDeadlock, formatGateDeadlock } from "./gatewatch.mjs";
|
|
103
106
|
import { recoveryEvidence } from "./render.mjs";
|
|
104
107
|
import { renderAlerts } from "../stats/render.mjs";
|
|
105
108
|
import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
|
|
106
109
|
import { laneSize, SIZES } from "./budget.mjs";
|
|
110
|
+
import { requeueKey, REQUEUABLE } from "./batch.mjs";
|
|
107
111
|
|
|
108
112
|
// A dependency that finished like this no longer blocks its dependents.
|
|
109
113
|
const SATISFIED = new Set(["merged", "invalid"]);
|
|
@@ -234,10 +238,10 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
234
238
|
* @returns {Promise<Array<{issue:number, outcome:string, pr:?number, cost_usd:number, minutes:number, detail?:string}>>}
|
|
235
239
|
*/
|
|
236
240
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
237
|
-
store = null, adopt = null, getParallel = null, getAdded = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
238
|
-
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
239
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, stopLane = null,
|
|
240
|
-
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS }) {
|
|
241
|
+
store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
242
|
+
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
243
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, getCancelled = null, getHeld = null, getGates = null, stopLane = null,
|
|
244
|
+
estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
|
|
241
245
|
const saved = store?.load?.() ?? null;
|
|
242
246
|
const order = saved ? [...saved.order] : [];
|
|
243
247
|
const set = new Set(order);
|
|
@@ -272,7 +276,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
272
276
|
const parkedBase = new Map(); // #4525: issue -> base sha its parked PR was last synced (or reported conflicting) against
|
|
273
277
|
let lastParkedSync = null;
|
|
274
278
|
const bodies = new Map(); // issue -> body, kept in memory for drift.holds only
|
|
279
|
+
const lifecycleTitles = new Set(); // #4657: milestones this batch merged into, re-evaluated at batch end
|
|
275
280
|
const driftHeld = new Map(); // issue -> flagged package dir holding it this pass
|
|
281
|
+
const integHeld = new Map(); // issue -> reason (red integration streak on its milestone lane, #4667) holding it this pass
|
|
276
282
|
const reg = sched?.registry ?? null;
|
|
277
283
|
const priorities = toMap(saved?.priorities); // issue -> priority number; only with the registry
|
|
278
284
|
const remoteMax = remote && Number.isInteger(remote.max) && remote.max >= 0 ? remote.max : 0;
|
|
@@ -301,6 +307,9 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
301
307
|
const recoveryMax = Number.isInteger(recovery?.maxAttempts) && recovery.maxAttempts > 0 ? recovery.maxAttempts : 0;
|
|
302
308
|
const recoveries = toMap(saved?.recoveries); // issue -> recovery lanes launched (#4267)
|
|
303
309
|
const recovering = toMap(saved?.recovering); // issue -> {code, attempt, detail, outcome, ...} of the recovery lane queued or running
|
|
310
|
+
const requeueApplied = new Set(saved?.requeueApplied ?? []); // #4550: `--requeue` entries already applied (batch `requeue` file)
|
|
311
|
+
const requeuedInfo = toMap(saved?.requeued); // #4550: issue -> {reason, by, at, prior: [earlier rows]}
|
|
312
|
+
const everRelabelled = new Set(saved?.relabelled ?? []); // #4656: issues the runner already relabelled; never parked on top
|
|
304
313
|
const relabelled = new Set(saved?.relabelled ?? []); // #4315: queued recoveries relabelled needs-human when a batch stopped
|
|
305
314
|
// #4267: decisions are recorded, never awaited: a failing or slow recorder cannot block the batch.
|
|
306
315
|
const decided = (d) => { try { Promise.resolve(decide?.(d)).catch(() => {}); } catch { /* best effort */ } };
|
|
@@ -314,6 +323,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
314
323
|
prefilter: toObj(checked), errorTimes, breaker: tripped, ...(budget ? { classes: toObj(classes), dispatch: { ...dispatch } } : {}),
|
|
315
324
|
...(reg ? { priorities: toObj(priorities) } : {}), ...(sandboxUse.size ? { sandbox: toObj(sandboxUse) } : {}),
|
|
316
325
|
...(recoveries.size || recovering.size ? { recoveries: toObj(recoveries), recovering: toObj(recovering) } : {}),
|
|
326
|
+
...(requeueApplied.size ? { requeueApplied: [...requeueApplied], requeued: toObj(requeuedInfo) } : {}),
|
|
317
327
|
...(relabelled.size ? { relabelled: [...relabelled] } : {}), ...(cancelled ? { cancelled } : {}),
|
|
318
328
|
...(estimate ? { estimates: toObj(estimates), enqueued: toObj(enqueued) } : {}), ...(heads.size ? { heads: [...heads] } : {}) });
|
|
319
329
|
};
|
|
@@ -489,14 +499,23 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
489
499
|
return true;
|
|
490
500
|
};
|
|
491
501
|
|
|
492
|
-
|
|
502
|
+
// #4687: a dependency skip is a waiting state while a blocker re-check can still re-admit it (`waiting`, cleared when the batch ends);
|
|
503
|
+
// the log row names the triggering issue.
|
|
504
|
+
const skip = (n, detail, trigger = null) => {
|
|
505
|
+
results.set(n, { issue: n, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail, ...(trigger !== null && dispatchCheck ? { waiting: true } : {}) });
|
|
506
|
+
update({ ...results.get(n), ...(trigger !== null ? { trigger } : {}) });
|
|
507
|
+
};
|
|
493
508
|
const requeue = (n, delayMs) => { pending.push(n); enqueued.set(n, now()); if (delayMs > 0) notBefore.set(n, now() + delayMs); else notBefore.delete(n); };
|
|
494
|
-
const
|
|
509
|
+
const TRANSPORT_TEXT = /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|socket hang up|HTTP 50[234]/i;
|
|
510
|
+
const TRANSPORT_COOLDOWN_MS = 5 * 60000;
|
|
511
|
+
let transportErrors = 0; // #4685: engine-errors in the current window that were link failures
|
|
512
|
+
const noteEngineError = (detail = "") => {
|
|
495
513
|
if (!breaker) return;
|
|
496
514
|
const t = now(), windowMs = breaker.window_min * 60000;
|
|
497
515
|
errorTimes = [...errorTimes.filter((x) => t - x < windowMs), t];
|
|
516
|
+
transportErrors = TRANSPORT_TEXT.test(String(detail)) ? Math.min(transportErrors + 1, errorTimes.length) : 0;
|
|
498
517
|
if (!tripped && errorTimes.length >= breaker.errors) {
|
|
499
|
-
tripped = { at: t, detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
|
|
518
|
+
tripped = { at: t, ...(transportErrors >= errorTimes.length ? { transport: true } : {}), detail: `circuit breaker: ${errorTimes.length} engine-errors within ${breaker.window_min} min; admissions stopped` };
|
|
500
519
|
update({ issue: null, outcome: "breaker", detail: tripped.detail });
|
|
501
520
|
}
|
|
502
521
|
};
|
|
@@ -540,11 +559,11 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
540
559
|
}
|
|
541
560
|
if (parked !== undefined) {
|
|
542
561
|
done();
|
|
543
|
-
const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human
|
|
562
|
+
const final = { ...row, outcome: "skipped", detail: `dependency #${parked} needs-human`, ...(dispatchCheck ? { waiting: true } : {}) };
|
|
544
563
|
results.set(n, final); save();
|
|
545
564
|
decided({ issue: n, what: `skipped: dependency #${parked} needs-human (not pulled into the batch)`, why: firstLine(row.detail) || "the lane reported an open dependency",
|
|
546
565
|
alternative: `pull #${parked} in and run it again` });
|
|
547
|
-
update(final);
|
|
566
|
+
update({ ...final, trigger: parked });
|
|
548
567
|
return;
|
|
549
568
|
}
|
|
550
569
|
}
|
|
@@ -584,7 +603,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
584
603
|
row.detail = `gave up after ${deferrals.get(n) ?? 0} deferrals: ${r?.detail ?? r?.code ?? ""}`;
|
|
585
604
|
}
|
|
586
605
|
if (row.outcome === "engine-error") {
|
|
587
|
-
noteEngineError();
|
|
606
|
+
noteEngineError(r?.detail);
|
|
588
607
|
if ((engineErrors.get(n) ?? 0) < ENGINE_ERROR_RELAUNCHES) {
|
|
589
608
|
engineErrors.set(n, (engineErrors.get(n) ?? 0) + 1);
|
|
590
609
|
requeue(n, 0); done(); save();
|
|
@@ -644,7 +663,16 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
644
663
|
if (!prior && row.outcome === "budget-exceeded" && typeof relabelNeedsHuman === "function") {
|
|
645
664
|
try { await relabelNeedsHuman({ issue: n, evidence: `Implement turn budget overrun (\`budget.exceeded\`) not recovered in this batch:\n${String(row.detail ?? "")}` }); } catch { /* best effort */ }
|
|
646
665
|
}
|
|
666
|
+
// #4656: a mechanical terminal park (recovery spent or none applies) moves to the remediation queue, not the human queue.
|
|
667
|
+
if (row.outcome === "needs-human" && !row.parkedQueued && !everRelabelled.has(n) && typeof relabelNeedsHuman === "function" && (cls => cls === "parked" || (cls === "recoverable" && prior))(classifyNeedsHuman({ ...row, issueEdited: r?.issueEdited === true }, { parked: true }))) { // a recoverable row with a finished recovery has used its attempts
|
|
668
|
+
try { const res = await relabelNeedsHuman({ issue: n, parked: { code: row.code, pr: Number.isInteger(row.pr) ? row.pr : null }, evidence: `Mechanical park \`${row.code}\`${Number.isInteger(row.pr) ? ` (PR #${row.pr})` : ""}:\n${String(row.detail ?? "")}` }); if (res !== false && res !== "failed" && res !== "skipped") row.parkedQueued = true; } catch { /* best effort */ }
|
|
669
|
+
}
|
|
647
670
|
results.set(n, row);
|
|
671
|
+
if (row.outcome === "merged" && lifecycle && r?.milestone) lifecycleTitles.add(String(r.milestone)); // #4657
|
|
672
|
+
if (row.outcome === "merged" && integration?.onMerged && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
|
|
673
|
+
// #4667: counts the merge toward the milestone's integration run; an error is a row, never the outcome.
|
|
674
|
+
try { await integration.onMerged(n, { ...row, lane: rec?.lane ?? r?.lane }, r); } catch (e) { update({ issue: n, outcome: "integration", detail: `integration check failed: ${String(e?.message ?? e).slice(0, 300)}` }); }
|
|
675
|
+
}
|
|
648
676
|
if (row.outcome === "merged" && drift?.onMerged && r?.structure && String(rec?.lane ?? r?.lane ?? "").startsWith("milestone/")) {
|
|
649
677
|
save(); update(row);
|
|
650
678
|
// Before the slot frees: the batch never ends with a fold pending, and block_dispatch sees the new flags.
|
|
@@ -742,7 +770,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
742
770
|
}
|
|
743
771
|
save();
|
|
744
772
|
|
|
745
|
-
const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
773
|
+
const heldDetail = (n) => cancelled ? "cancelled by operator" : opHeld.has(n) ? "held by operator" : tripped?.detail ?? (integHeld.has(n) ? `integration: ${integHeld.get(n)}` : (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run"));
|
|
746
774
|
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
747
775
|
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
748
776
|
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
@@ -755,7 +783,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
755
783
|
queued: q.attempt, cause: why, recoveryMax });
|
|
756
784
|
let res;
|
|
757
785
|
try { res = await relabelNeedsHuman({ issue: n, evidence }); } catch { res = "failed"; }
|
|
758
|
-
if (res !== false && res !== "failed") relabelled.add(n); // a failed label add is retried by a resume
|
|
786
|
+
if (res !== false && res !== "failed") { relabelled.add(n); everRelabelled.add(n); } // a failed label add is retried by a resume
|
|
759
787
|
}
|
|
760
788
|
save();
|
|
761
789
|
})());
|
|
@@ -782,10 +810,47 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
782
810
|
update({ issue: null, outcome: "added", detail: list });
|
|
783
811
|
return fresh.length;
|
|
784
812
|
};
|
|
785
|
-
// #
|
|
813
|
+
// #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
|
|
814
|
+
// return to pending, the prior rows kept in history; edges stay, so a dependent still waits for the issue to merge.
|
|
815
|
+
const drainRequeues = async () => {
|
|
816
|
+
if (!getRequeued) return 0;
|
|
817
|
+
let got = [];
|
|
818
|
+
try { got = getRequeued() ?? []; } catch { got = []; }
|
|
819
|
+
const fresh = (Array.isArray(got) ? got : []).filter((e) => Number.isSafeInteger(e?.issue) && !requeueApplied.has(requeueKey(e)));
|
|
820
|
+
let n = 0;
|
|
821
|
+
for (const e of fresh) {
|
|
822
|
+
requeueApplied.add(requeueKey(e));
|
|
823
|
+
const row = results.get(e.issue);
|
|
824
|
+
if (!row || !REQUEUABLE(row.outcome) || running.has(e.issue)) continue;
|
|
825
|
+
const moved = [e.issue], priors = new Map([[e.issue, row]]);
|
|
826
|
+
results.delete(e.issue);
|
|
827
|
+
for (let i = 0; i < moved.length; i++) { // dependents skipped behind a requeued issue (fixpoint)
|
|
828
|
+
for (const [m, r] of [...results]) {
|
|
829
|
+
if (r?.outcome !== "skipped" || Number(/^dependency #(\d+)/.exec(r.detail ?? "")?.[1]) !== moved[i]) continue;
|
|
830
|
+
priors.set(m, r); results.delete(m); moved.push(m);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
for (const m of moved) {
|
|
834
|
+
for (const x of [requeues, deferrals, engineErrors, recoveries, recovering]) x.delete(m);
|
|
835
|
+
relabelled.delete(m);
|
|
836
|
+
if (m === e.issue) requeuedInfo.set(m, { reason: e.reason, by: e.by, at: e.at, prior: [...(requeuedInfo.get(m)?.prior ?? []), priors.get(m)] });
|
|
837
|
+
if (!pending.includes(m)) requeue(m, 0);
|
|
838
|
+
}
|
|
839
|
+
save();
|
|
840
|
+
const list = moved.filter((m) => m !== e.issue).map((m) => `#${m}`);
|
|
841
|
+
decided({ issue: e.issue, what: `requeued by ${e.by ?? "operator"} (forgedock orchestrate --requeue); prior outcome ${row.outcome}${list.length ? `; dependents back to pending: ${list.join(", ")}` : ""}`,
|
|
842
|
+
why: e.reason || "operator decision", alternative: "start a second batch" });
|
|
843
|
+
update({ issue: e.issue, outcome: "requeued", detail: `${e.reason || "operator decision"} (was ${row.outcome})` });
|
|
844
|
+
for (const m of moved) if (m !== e.issue) update({ issue: m, outcome: "requeued", detail: `dependency #${e.issue} requeued`, trigger: e.issue }); // #4687
|
|
845
|
+
n++;
|
|
846
|
+
}
|
|
847
|
+
if (fresh.length) save();
|
|
848
|
+
return n;
|
|
849
|
+
};
|
|
850
|
+
// #4376: one SIGTERM per running local lane (their teardown saves the work); remote lanes run to their result unless the cancel carries `stopRemote` (#4612).
|
|
786
851
|
const stopSent = new Set();
|
|
787
852
|
function stop(n, rec) {
|
|
788
|
-
if (!stopLane || rec?.remote || stopSent.has(n)) return;
|
|
853
|
+
if (!stopLane || (rec?.remote && cancelled?.stopRemote !== true) || stopSent.has(n)) return; // #4612: remote lanes only when the cancel asked for it
|
|
789
854
|
stopSent.add(n);
|
|
790
855
|
try { Promise.resolve(stopLane(n, rec)).catch(() => {}); } catch { /* best effort */ }
|
|
791
856
|
}
|
|
@@ -807,6 +872,22 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
807
872
|
update({ issue: null, outcome: "release", detail: fmt(freed) });
|
|
808
873
|
}
|
|
809
874
|
};
|
|
875
|
+
// #4656: a gate (hold list, settle, ship pre-flight) waiting on an issue the scheduler will not admit: one decision and row per (gate, issue).
|
|
876
|
+
const gateSeen = new Map(), gateReported = new Set();
|
|
877
|
+
const checkGates = () => {
|
|
878
|
+
const gates = [];
|
|
879
|
+
if (opHeld.size) gates.push({ name: "hold", waitsOn: [...opHeld] });
|
|
880
|
+
if (getGates) { try { gates.push(...(getGates() ?? [])); } catch { /* best effort */ } }
|
|
881
|
+
if (!gates.length) return;
|
|
882
|
+
const unadmittable = (n) => { const x = results.get(n); return !(x && (x.outcome === "needs-human" || x.parkedQueued)); };
|
|
883
|
+
for (const d of detectGateDeadlock({ gates, admissible: unadmittable, now: now(), firstSeen: gateSeen })) {
|
|
884
|
+
const key = `${d.gate}#${d.issue}`;
|
|
885
|
+
if (gateReported.has(key)) continue;
|
|
886
|
+
gateReported.add(key);
|
|
887
|
+
decided({ issue: d.issue, what: formatGateDeadlock(d), why: "the gate waits on a parked issue the scheduler will not admit", alternative: "/work-on:remediate the parked issue or release the gate" });
|
|
888
|
+
update({ issue: d.issue, outcome: "GATE_DEADLOCK", detail: formatGateDeadlock(d) });
|
|
889
|
+
}
|
|
890
|
+
};
|
|
810
891
|
const checkCancel = () => {
|
|
811
892
|
if (!getCancelled) return;
|
|
812
893
|
let c = null;
|
|
@@ -838,6 +919,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
838
919
|
// #4595: a skipped row whose blocker (`dependency #B ...`) was finished outside the lane is re-admitted: B is re-checked
|
|
839
920
|
// through `dispatchCheck` (at most every WATCH_EVERY_MS, and once before the batch would end); fail-open.
|
|
840
921
|
let lastWatch = null;
|
|
922
|
+
const settled = new Set(); // #4687: blockers whose close is final (not planned): their dependents stay skipped, no more re-checks
|
|
841
923
|
const readmitted = new Set(); // "n:b": re-admit n on blocker b once; a lane that re-skips on b stays skipped
|
|
842
924
|
const blockerOf = (n) => { const r = results.get(n); const m = r?.outcome === "skipped" ? /^dependency #(\d+) /.exec(r.detail ?? "") : null; return m ? Number(m[1]) : null; };
|
|
843
925
|
const watchable = () => !!dispatchCheck && [...results.keys()].some((n) => blockerOf(n) !== null);
|
|
@@ -847,10 +929,18 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
847
929
|
if (!final && lastWatch !== null && t0 - lastWatch < WATCH_EVERY_MS) return;
|
|
848
930
|
lastWatch = t0;
|
|
849
931
|
const resolved = new Set();
|
|
850
|
-
const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null));
|
|
932
|
+
const roots = new Set([...results.keys()].filter((n) => !readmitted.has(`${n}:${blockerOf(n)}`)).map(blockerOf).filter((b) => b !== null && blockerOf(b) === null && !settled.has(b)));
|
|
851
933
|
for (const b of roots) {
|
|
852
934
|
let g = null;
|
|
853
935
|
try { g = (await dispatchCheck(b)) ?? null; } catch { g = null; }
|
|
936
|
+
if (g?.final) { // #4687: closed as not planned: terminal for its dependents
|
|
937
|
+
settled.add(b);
|
|
938
|
+
for (const n of results.keys()) {
|
|
939
|
+
const r = results.get(n);
|
|
940
|
+
if (blockerOf(n) === b && r.waiting) { const { waiting, ...rest } = r; results.set(n, rest); update({ ...rest, trigger: b }); }
|
|
941
|
+
}
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
854
944
|
if (!g || !SATISFIED.has(g.outcome)) continue;
|
|
855
945
|
resolved.add(b);
|
|
856
946
|
const row = results.get(b);
|
|
@@ -869,6 +959,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
869
959
|
readmitted.add(`${n}:${b}`);
|
|
870
960
|
results.delete(n);
|
|
871
961
|
pending.push(n);
|
|
962
|
+
update({ issue: n, outcome: "readmitted", detail: `dependency #${b} merged or closed`, trigger: b }); // #4687
|
|
872
963
|
decided({ issue: n, what: `re-admitted: dependency #${b} merged or closed after this issue was skipped`, why: "the blocker was finished outside this batch lane",
|
|
873
964
|
alternative: "leave it skipped until the next batch" });
|
|
874
965
|
}
|
|
@@ -879,6 +970,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
879
970
|
for (;;) {
|
|
880
971
|
if (signal?.aborted) throw halt();
|
|
881
972
|
await drainAdds();
|
|
973
|
+
await drainRequeues();
|
|
882
974
|
checkCancel();
|
|
883
975
|
await watchBlockers(!pending.length && !running.size);
|
|
884
976
|
if (!pending.length && !running.size) break;
|
|
@@ -889,18 +981,25 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
889
981
|
const n = pending[i];
|
|
890
982
|
if (results.has(n)) { pending.splice(i, 1); continue; }
|
|
891
983
|
const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
|
|
892
|
-
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}
|
|
984
|
+
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`, bad); again = true; }
|
|
893
985
|
}
|
|
894
986
|
}
|
|
895
987
|
const t = now();
|
|
896
988
|
const holdUntil = heldUntil();
|
|
897
989
|
let started = 0;
|
|
898
990
|
driftHeld.clear();
|
|
991
|
+
integHeld.clear();
|
|
899
992
|
opHeldHit.clear();
|
|
900
993
|
refreshHeld();
|
|
994
|
+
checkGates();
|
|
901
995
|
const isReady = (n) => {
|
|
902
996
|
if (opHeld.has(n)) { opHeldHit.add(n); return false; }
|
|
903
997
|
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
998
|
+
if (integration?.holds) {
|
|
999
|
+
let why = null;
|
|
1000
|
+
try { why = integration.holds(n, { lane: lanes.get(n)?.lane ?? null }); } catch { why = null; }
|
|
1001
|
+
if (why) { integHeld.set(n, why); return false; }
|
|
1002
|
+
}
|
|
904
1003
|
if (!drift?.holds) return true;
|
|
905
1004
|
let pkg = null;
|
|
906
1005
|
try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
|
|
@@ -946,6 +1045,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
946
1045
|
}
|
|
947
1046
|
}
|
|
948
1047
|
if (reg) withdrawExcept(wanted);
|
|
1048
|
+
// #4685: a breaker tripped only by link failures (a router restart) closes itself after a cooldown
|
|
1049
|
+
if (tripped?.transport && now() - tripped.at >= TRANSPORT_COOLDOWN_MS) { tripped = null; errorTimes = []; transportErrors = 0; save(); }
|
|
949
1050
|
if (tripped && !running.size) { await drainAdds(); break; } // breaker: let running lanes finish, keep the rest pending for a resume
|
|
950
1051
|
if (cancelled && !running.size) { await drainAdds(); break; } // #4376: cancel: the rest end held for a resume
|
|
951
1052
|
if (budgetStop && !running.size) { await drainAdds(); break; } // execution budget (stop): the same, the rest end held
|
|
@@ -970,8 +1071,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
970
1071
|
if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
|
|
971
1072
|
if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
972
1073
|
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
1074
|
+
if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
|
|
973
1075
|
if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
|
|
974
|
-
if (getAdded && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408: an add while only lanes run
|
|
975
1076
|
if (syncParked && running.size && parkedPrs().length) wake = Math.min(wake ?? syncEveryMs, Math.max(0, syncEveryMs - (t - (lastParkedSync ?? t)))); // #4525
|
|
976
1077
|
if (running.size && watchable()) wake = Math.min(wake ?? WATCH_EVERY_MS, WATCH_EVERY_MS); // #4595: re-check the blockers of skipped rows
|
|
977
1078
|
if (tripped) wake = null;
|
|
@@ -984,7 +1085,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
984
1085
|
else if (pending.length && waits.length) await nap(Math.min(wake, getCancelled ? pollMs : Infinity)); // wake is poll-capped by a hold or a live K; #4454: nap wakes on a stop
|
|
985
1086
|
else if (pending.length && budgetHeld) { if (await drainAdds()) continue; break; } // maintenance the budget does not admit now: held for a later batch
|
|
986
1087
|
else if (pending.length && opHeldHit.size && !pending.some((n) => !opHeldHit.has(n) && isReady(n))) { if (await drainAdds()) continue; break; } // #4592: held by the operator: pending for a --resume
|
|
987
|
-
else if (pending.length && driftHeld.size) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch): pending for a later batch
|
|
1088
|
+
else if (pending.length && (driftHeld.size || integHeld.size)) { if (await drainAdds()) continue; break; } // held by a drift flag (block_dispatch) or a red integration streak (#4667): pending for a later batch
|
|
988
1089
|
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
989
1090
|
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
990
1091
|
save();
|
|
@@ -997,8 +1098,14 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
997
1098
|
}
|
|
998
1099
|
if (reg) withdrawExcept(new Set());
|
|
999
1100
|
await relabelQueued();
|
|
1101
|
+
// #4657: a milestone whose `close_on` state is reached closes here; a failure is a row, never an outcome change.
|
|
1102
|
+
if (lifecycle?.onBatchEnd && lifecycleTitles.size && !halted) {
|
|
1103
|
+
try { await lifecycle.onBatchEnd([...lifecycleTitles]); } catch (e) { onUpdate({ issue: 0, outcome: "lifecycle", detail: `lifecycle check failed: ${String(e?.message ?? e).slice(0, 300)}` }); }
|
|
1104
|
+
}
|
|
1105
|
+
for (const [n, r] of results) if (r?.waiting) { const { waiting, ...rest } = r; results.set(n, rest); } // #4687: the batch ended: skipped for good
|
|
1000
1106
|
save();
|
|
1001
|
-
|
|
1107
|
+
const withRequeue = (r) => (r && requeuedInfo.has(r.issue) ? { ...r, requeued: { reason: requeuedInfo.get(r.issue).reason, by: requeuedInfo.get(r.issue).by, prior: requeuedInfo.get(r.issue).prior.map((p) => ({ outcome: p.outcome, detail: p.detail ?? null, pr: p.pr ?? null })) } } : r);
|
|
1108
|
+
return order.map((n) => withRequeue(results.get(n)) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
1002
1109
|
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
1003
1110
|
...(recovering.get(n).result_code ? { result_code: recovering.get(n).result_code } : {}), detail: firstLine(recovering.get(n).detail ?? recovering.get(n).code) } } : {}) });
|
|
1004
1111
|
}
|
|
@@ -1027,14 +1134,19 @@ export const OUTCOME_LINE_MAX = 200;
|
|
|
1027
1134
|
export function formatOutcomeLists(rows) {
|
|
1028
1135
|
const out = [];
|
|
1029
1136
|
const one = (x) => String(x ?? "").split("\n")[0].slice(0, OUTCOME_LINE_MAX);
|
|
1137
|
+
const requeued = (rows ?? []).filter((r) => r?.requeued); // #4550: operator --requeue after a decision; the row is the latest outcome
|
|
1138
|
+
if (requeued.length) out.push(`requeued: ${requeued.map((r) => `#${r.issue} (${one(r.requeued.reason)})`).join(" ")}`);
|
|
1030
1139
|
const rec = (rows ?? []).filter((r) => r?.recovered);
|
|
1031
1140
|
if (rec.length) out.push(`recovered: ${rec.map((r) => `#${r.issue} (${r.recovered})`).join(" ")}`);
|
|
1032
1141
|
for (const r of rows ?? []) {
|
|
1033
1142
|
if (!r || r.recovered) continue;
|
|
1034
1143
|
const human = r.outcome === "needs-human" && classifyNeedsHuman(r) === "human";
|
|
1144
|
+
if (r.parkedQueued && r.outcome === "needs-human") continue; // #4656: listed on the remediation line below, not as unrecovered
|
|
1035
1145
|
if (!human && (r.outcome === "needs-human" || r.outcome === "budget-exceeded" || 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)})`);
|
|
1036
1146
|
}
|
|
1037
1147
|
const esc = (rows ?? []).filter((r) => r?.outcome === "needs-human" && classifyNeedsHuman(r) === "human");
|
|
1148
|
+
const rq = (rows ?? []).filter((r) => r?.outcome === "needs-human" && r.parkedQueued); // #4656: parked past recovery: the single remediation queue, not escalation
|
|
1149
|
+
if (rq.length) out.push(`remediation queue (/work-on:remediate): ${rq.map((r) => `#${r.issue} (parked:${r.code}${r.pr ? ` PR #${r.pr}` : ""})`).join(" ")}`);
|
|
1038
1150
|
const parked = formatParkedLine(rows); // #4389: one line for every parked post-deploy follow-up, never an escalation
|
|
1039
1151
|
if (parked) out.push(parked);
|
|
1040
1152
|
if (esc.length) out.push(["escalations (human decisions, asked once):", ...esc.map((r) => `- #${r.issue} (${r.code}): ${one(r.detail)}`)].join("\n"));
|
|
@@ -1045,7 +1157,7 @@ export function formatTable(rows) {
|
|
|
1045
1157
|
// #4255: sandbox minutes and cost next to the token cost, only when some lane ran in a sandbox.
|
|
1046
1158
|
const sbx = rows.some((r) => r?.sandbox);
|
|
1047
1159
|
const head = ["issue", "outcome", "PR", "cost", ...(sbx ? ["sbx-min", "sbx-$"] : []), "min"];
|
|
1048
|
-
const body = rows.map((r) => [`#${r.issue}`, r.outcome + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
|
|
1160
|
+
const body = rows.map((r) => [`#${r.issue}`, (r.waiting && /^dependency #(\d+) /.test(r.detail ?? "") ? `waiting on #${/^dependency #(\d+) /.exec(r.detail)[1]}` : r.outcome) + (["skipped", "held"].includes(r.outcome) && r.detail ? ` (${r.detail})` : r.prefilter?.closed || r.prefilter?.parked ? " (pre-filter)" : ""), r.pr ? `#${r.pr}` : "-", `$${(r.cost_usd ?? 0).toFixed(2)}`,
|
|
1049
1161
|
...(sbx ? (r.sandbox ? [(Number(r.sandbox.minutes) || 0).toFixed(1), `$${(Number(r.sandbox.cost_usd) || 0).toFixed(2)}`] : ["-", "-"]) : []), String(r.minutes ?? 0)]);
|
|
1050
1162
|
const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => b[i].length)));
|
|
1051
1163
|
const line = (c) => c.map((x, i) => x.padEnd(w[i])).join(" ").trimEnd();
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Parked post-deploy follow-up sweep (#4689). A follow-up parked by the #4389 gate (`awaiting-promotion`, `awaiting-due`,
|
|
5
|
+
* `awaiting-measurement`) is re-evaluated on the daemon timer, next to `sweepDue`, instead of waiting for the next batch:
|
|
6
|
+
* `prefilterIssue` with `gateOnly` re-runs the same deterministic gate (git ancestry, due date, soak window, precondition,
|
|
7
|
+
* named checks; no model call, no lane). A released issue loses its wait labels and is picked up by the next scheduler pass;
|
|
8
|
+
* one that is still waiting is left as it is.
|
|
9
|
+
*/
|
|
10
|
+
import { prefilterIssue, lazyDefaultBranch, AWAITING_PROMOTION_LABEL, AWAITING_DUE_LABEL } from "./prefilter.mjs";
|
|
11
|
+
import { AWAITING_MEASUREMENT_LABEL } from "./postdeploy-check.mjs";
|
|
12
|
+
|
|
13
|
+
export const PARKED_LABELS = [AWAITING_PROMOTION_LABEL, AWAITING_DUE_LABEL, AWAITING_MEASUREMENT_LABEL];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Re-evaluate every parked follow-up of the repo. Sequential; an error is an `error` row, never a throw.
|
|
17
|
+
* Outcomes: released | closed | still-parked | skipped | error.
|
|
18
|
+
*/
|
|
19
|
+
export async function sweepParked({ repo, cwd = process.cwd(), io, defaultBranch = null, preconditionCmd = null, minAge = null, dryRun = false, onResult = null } = {}) {
|
|
20
|
+
const found = new Set();
|
|
21
|
+
for (const label of PARKED_LABELS) {
|
|
22
|
+
try {
|
|
23
|
+
const list = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--label", label, "--limit", "100", "--json", "number"])));
|
|
24
|
+
for (const i of Array.isArray(list) ? list : []) if (Number.isInteger(i?.number)) found.add(i.number);
|
|
25
|
+
} catch { /* one unreadable label list: the others still run */ }
|
|
26
|
+
}
|
|
27
|
+
const resolve = defaultBranch ?? lazyDefaultBranch({ io, repo, cwd });
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (const issue of [...found].sort((a, b) => a - b)) {
|
|
30
|
+
let row;
|
|
31
|
+
try {
|
|
32
|
+
const r = await prefilterIssue({ issue, repo, cwd, io, dryRun, defaultBranch: resolve, preconditionCmd, minAge, gateOnly: true });
|
|
33
|
+
row = { issue, outcome: /^pre-filter error/.test(r.detail) ? "error" : r.closed ? "closed" : r.parked ? "still-parked" : /^released/.test(r.detail) ? "released" : "skipped", detail: r.detail, kind: r.kind ?? null };
|
|
34
|
+
} catch (e) { row = { issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) }; }
|
|
35
|
+
rows.push(row);
|
|
36
|
+
try { if (!dryRun) await onResult?.(row); } catch { /* an event sink never fails the sweep */ }
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One line, e.g. `parked: released #4 #5; closed #6`; "" when nothing changed. */
|
|
42
|
+
export function formatParkedSweepSummary(rows) {
|
|
43
|
+
const parts = [["released", "released"], ["closed", "closed"], ["error", "error"]].map(([k, l]) => [l, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)]).filter(([, a]) => a.length).map(([l, a]) => `${l} ${a.join(" ")}`);
|
|
44
|
+
return parts.length ? `parked: ${parts.join("; ")}` : "";
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { sweepParked, formatParkedSweepSummary } from "./parked-sweep.mjs";
|
|
6
|
+
|
|
7
|
+
const MERGE = "a".repeat(40);
|
|
8
|
+
const item = (extra = "") => `- [ ] \`R2: the window shows a row\` — post-deploy: [check:smoke] ${extra} after promotion`;
|
|
9
|
+
const bodyOf = (extra) => `## AC\n\n${item(extra)}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
|
|
10
|
+
const CHECKS = JSON.stringify({ smoke: "echo ok" });
|
|
11
|
+
|
|
12
|
+
/** Fake repo: `parked` maps issue -> {labels, body}; `promoted` says whether the parent's merge SHA is on origin/main. */
|
|
13
|
+
function world({ parked, promoted, now = Date.parse("2026-11-04T00:00:00Z") }) {
|
|
14
|
+
const w = { edits: [], closed: [], comments: [], commits: [] };
|
|
15
|
+
w.io = {
|
|
16
|
+
now: () => now,
|
|
17
|
+
gh: async (a) => {
|
|
18
|
+
const k = a.slice(0, 2).join(" ");
|
|
19
|
+
if (k === "issue list") { const l = a[a.indexOf("--label") + 1]; return JSON.stringify(Object.entries(parked).filter(([, v]) => v.labels.includes(l)).map(([n]) => ({ number: Number(n) }))); }
|
|
20
|
+
if (k === "issue view") { const n = Number(a[2]); return JSON.stringify({ number: n, state: "OPEN", title: "", comments: [], ...parked[n], labels: parked[n].labels.map((name) => ({ name })) }); }
|
|
21
|
+
if (k === "pr list") return JSON.stringify([{ number: 90, title: "fix (#40)", body: "", mergeCommit: { oid: MERGE } }]);
|
|
22
|
+
if (k === "issue edit") { w.edits.push(a); return ""; }
|
|
23
|
+
if (k === "issue comment") { w.comments.push(a); return ""; }
|
|
24
|
+
if (k === "issue close") { w.closed.push(Number(a[2])); return ""; }
|
|
25
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
26
|
+
},
|
|
27
|
+
git: async (a) => {
|
|
28
|
+
if (a[0] === "fetch") return "";
|
|
29
|
+
if (a[0] === "rev-parse") return "deadbeef\n";
|
|
30
|
+
if (a[0] === "show") return CHECKS;
|
|
31
|
+
if (a[0] === "merge-base") { if (promoted) return ""; throw Object.assign(new Error("exit 1"), { code: 1 }); }
|
|
32
|
+
throw new Error(`unexpected git ${a.join(" ")}`);
|
|
33
|
+
},
|
|
34
|
+
spawnCheck: async () => ({ code: 0, stdout: "ok", stderr: "" }),
|
|
35
|
+
};
|
|
36
|
+
return w;
|
|
37
|
+
}
|
|
38
|
+
const sweep = (w, extra = {}) => sweepParked({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main", ...extra });
|
|
39
|
+
|
|
40
|
+
describe("parked follow-up sweep (#4689)", () => {
|
|
41
|
+
it("one pass after the parent's commit reaches the default branch removes awaiting-promotion and measures; before it nothing changes", async () => {
|
|
42
|
+
const parked = { 41: { labels: ["awaiting-promotion"], body: bodyOf() } };
|
|
43
|
+
const before = world({ parked, promoted: false });
|
|
44
|
+
const rows0 = await sweep(before);
|
|
45
|
+
assert.deepEqual(rows0.map((r) => [r.issue, r.outcome, r.kind]), [[41, "still-parked", "awaiting-promotion"]]);
|
|
46
|
+
assert.deepEqual(before.closed, []);
|
|
47
|
+
const after = world({ parked, promoted: true });
|
|
48
|
+
const rows = await sweep(after, { dryRun: false });
|
|
49
|
+
assert.ok(after.edits.some((a) => a.includes("--remove-label") && a.includes("awaiting-promotion")), "label removed");
|
|
50
|
+
assert.equal(rows[0].issue, 41); assert.notEqual(rows[0].outcome, "still-parked");
|
|
51
|
+
assert.equal(formatParkedSweepSummary([{ issue: 41, outcome: "released" }, { issue: 5, outcome: "error" }]), "parked: released #41; error #5");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("a due date in the past is released, a future one stays parked", async () => {
|
|
55
|
+
const parked = { 50: { labels: ["awaiting-due", "due:2026-11-03"], body: bodyOf("due:2026-11-03") }, 51: { labels: ["awaiting-due"], body: bodyOf("due:2026-12-01") } };
|
|
56
|
+
const w = world({ parked, promoted: true });
|
|
57
|
+
const rows = await sweep(w);
|
|
58
|
+
assert.equal(rows.find((r) => r.issue === 51).outcome, "still-parked");
|
|
59
|
+
assert.notEqual(rows.find((r) => r.issue === 50).outcome, "still-parked");
|
|
60
|
+
assert.ok(w.edits.some((a) => a[2] === "50" && a.includes("--remove-label") && /awaiting-due,due:2026-11-03/.test(a.join(" "))));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("an unreadable issue is an error row, never a throw; a dry run changes nothing", async () => {
|
|
64
|
+
const w = world({ parked: { 60: { labels: ["awaiting-promotion"], body: bodyOf() } }, promoted: true });
|
|
65
|
+
const g = w.io.gh; w.io.gh = async (a) => { if (a[1] === "view") throw new Error("boom"); return g(a); };
|
|
66
|
+
assert.equal((await sweep(w))[0].outcome, "error");
|
|
67
|
+
const w2 = world({ parked: { 61: { labels: ["awaiting-promotion"], body: bodyOf() } }, promoted: true });
|
|
68
|
+
await sweep(w2, { dryRun: true });
|
|
69
|
+
assert.deepEqual(w2.edits, []); assert.deepEqual(w2.closed, []);
|
|
70
|
+
});
|
|
71
|
+
});
|