forgedock 1.36.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/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +10 -1
- package/bin/engine/events.test.mjs +15 -1
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +1 -1
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +268 -37
- package/bin/engine.mjs +3 -1
- package/bin/forgedock.mjs +108 -5
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +10 -4
- package/bin/lean/batch.test.mjs +75 -1
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +125 -11
- package/bin/lean/conformance.test.mjs +122 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +29 -14
- package/bin/lean/execution.mjs +60 -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/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lean.mjs +134 -28
- package/bin/lean/lean.test.mjs +239 -22
- 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 +83 -15
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +38 -6
- package/bin/lean/postdeploy-check.test.mjs +62 -2
- package/bin/lean/postdeploy-monitor.mjs +12 -2
- package/bin/lean/postdeploy-monitor.test.mjs +27 -1
- package/bin/lean/prefilter.mjs +83 -23
- package/bin/lean/prefilter.test.mjs +73 -1
- package/bin/lean/probe.mjs +2 -2
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +15 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
- package/bin/lean/sandbox-remote.mjs +4 -4
- package/bin/lean/schedd.mjs +53 -9
- package/bin/lean/schedd.test.mjs +77 -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/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/index.mjs +21 -2
- 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 +8 -1
- 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 +71 -2
- package/docs/EVENTS.md +1 -4
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +20 -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/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/file-deferred-followups.sh +285 -77
- package/scripts/file-deferred-followups.test.sh +111 -4
- 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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/** #4662: bot token lifetime from the stamp scripts/refresh-bot-token.sh writes (~/.forge/bot-token.json). Pure; `doctor` renders it. */
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
export const MIN_REMAINING_MS = 10 * 60 * 1000;
|
|
9
|
+
export const MAX_AGE_MS = 50 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export const stampPath = (env = process.env) => join(env.FORGE_HOME_DIR || join(homedir(), ".forge"), "bot-token.json");
|
|
12
|
+
|
|
13
|
+
/** @returns {{configured:boolean, ok:boolean, remainingMs?:number, ageMs?:number, refreshedAt?:string, reason?:string}} */
|
|
14
|
+
export function botTokenStatus({ now = Date.now(), env = process.env, read = (p) => readFileSync(p, "utf8") } = {}) {
|
|
15
|
+
let raw;
|
|
16
|
+
try { raw = read(stampPath(env)); } catch { return { configured: false, ok: true }; }
|
|
17
|
+
let s;
|
|
18
|
+
try { s = JSON.parse(raw); } catch { return { configured: true, ok: false, reason: "bot-token.json is not valid JSON" }; }
|
|
19
|
+
const refreshed = Date.parse(s?.refreshedAt), expires = Date.parse(s?.expiresAt);
|
|
20
|
+
if (!Number.isFinite(refreshed) || !Number.isFinite(expires)) return { configured: true, ok: false, reason: "bot-token.json has no refreshedAt/expiresAt" };
|
|
21
|
+
const remainingMs = expires - now, ageMs = now - refreshed;
|
|
22
|
+
const out = { configured: true, remainingMs, ageMs, refreshedAt: s.refreshedAt };
|
|
23
|
+
if (remainingMs < MIN_REMAINING_MS) return { ...out, ok: false, reason: remainingMs <= 0 ? "token expired" : "less than 10 min of token lifetime remain" };
|
|
24
|
+
if (ageMs > MAX_AGE_MS) return { ...out, ok: false, reason: "no refresh in the last 50 min" };
|
|
25
|
+
return { ...out, ok: true };
|
|
26
|
+
}
|
package/bin/engine/events.mjs
CHANGED
|
@@ -65,11 +65,20 @@ export function experimentEvent(report) {
|
|
|
65
65
|
return { type: "experiment.verdict", data: { experiment: report.id, verdict, detail: `${passes.length} target(s)` }, ctx: {} };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** A fix reached the default branch -> release.promoted; null without a fix.
|
|
68
|
+
/** A fix reached the default branch -> release.promoted; null without a fix. Emitted by the probe sweep (probeSweepEvents). */
|
|
69
69
|
export function promotedEvent({ fix, default_branch } = {}) {
|
|
70
70
|
return typeof fix === "string" && fix ? { type: "release.promoted", data: { fix, default_branch }, ctx: {} } : null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Events for one probe-sweep row. A probe pass/fail row proves the fix is on the default branch (probeIssue gates on onDefault) and is
|
|
75
|
+
* recorded once per fix, so it also yields release.promoted. Monitor rows carry no defaultBranch and never do (they repeat per window).
|
|
76
|
+
*/
|
|
77
|
+
export function probeSweepEvents(row) {
|
|
78
|
+
const promoted = row?.defaultBranch && (row.outcome === "pass" || row.outcome === "fail") ? promotedEvent({ fix: row.fix, default_branch: row.defaultBranch }) : null;
|
|
79
|
+
return [probeEvent(row), regressionEvent(row), promoted && { ...promoted, ctx: { issue: row.issue } }].filter(Boolean);
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
/**
|
|
74
83
|
* Pure mapping of one run-log record to {type, data, ctx} or null. `cost` is the lane's running spend, kept by the caller
|
|
75
84
|
* from PHASE_ATTEMPT_USAGE (`cost_usd_delta` when present, else `cost_usd`).
|
|
@@ -7,7 +7,7 @@ import { readFileSync, existsSync, writeFileSync } from "node:fs";
|
|
|
7
7
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { createHmac } from "node:crypto";
|
|
10
|
-
import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
|
|
10
|
+
import { EVENT_TYPES, EVENT_VERSION, buildEvent, mapRunRecord, makeEventEmitter, probeEvent, probeSweepEvents, regressionEvent, experimentEvent, promotedEvent, sign, webhookUrlOk, postWebhook } from "./events.mjs";
|
|
11
11
|
|
|
12
12
|
const tmp = () => tmpDir("forge-events-");
|
|
13
13
|
const fixed = () => new Date("2026-10-26T12:00:00Z");
|
|
@@ -57,6 +57,20 @@ describe("event contract (v1)", () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
describe("probeSweepEvents emits release.promoted (#4644)", () => {
|
|
61
|
+
it("probe pass/fail rows with defaultBranch add release.promoted", () => {
|
|
62
|
+
const ev = probeSweepEvents({ issue: 3, outcome: "pass", fix: "abc", defaultBranch: "main", detail: "ok" });
|
|
63
|
+
assert.deepEqual(ev.map((e) => e.type), ["probe.passed", "release.promoted"]);
|
|
64
|
+
assert.deepEqual(ev[1].data, { fix: "abc", default_branch: "main" }); assert.equal(ev[1].ctx.issue, 3);
|
|
65
|
+
assert.equal(probeSweepEvents({ issue: 3, outcome: "fail", fix: "abc", defaultBranch: "main" }).length, 2);
|
|
66
|
+
});
|
|
67
|
+
it("monitor rows, awaiting-promotion and errors do not", () => {
|
|
68
|
+
assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "pass", fix: "abc" }).map((e) => e.type), ["probe.passed"]);
|
|
69
|
+
assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "awaiting-promotion", fix: "abc", defaultBranch: "main" }), []);
|
|
70
|
+
assert.deepEqual(probeSweepEvents({ issue: 3, outcome: "error" }), []);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
60
74
|
describe("regression, experiment and promoted builders (#4632)", () => {
|
|
61
75
|
it("regressionEvent: only a fail row that filed a regression issue", () => {
|
|
62
76
|
assert.equal(regressionEvent({ issue: 5, outcome: "pass" }), null);
|
|
@@ -32,17 +32,20 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
32
32
|
REMEDIATE_UNFIXABLE: "remediate.unfixable",
|
|
33
33
|
REMEDIATE_INPR_REQUESTED: "remediate.inpr_requested",
|
|
34
34
|
CI_RED: "ci.red",
|
|
35
|
+
CI_DCO: "ci.dco", // forge#4651: a failed DCO check (or no identity to sign off the lane's commits); never recoverable: a new commit cannot fix history
|
|
35
36
|
CI_TIMEOUT: "ci.timeout",
|
|
36
37
|
BASE_CONFLICT: "base.conflict",
|
|
37
38
|
RATE_LIMITED: "rate_limited",
|
|
38
39
|
HOST_BUSY: "host.busy",
|
|
39
40
|
GH_UNAVAILABLE: "gh.unavailable", // forge#4157: `gh` unreachable after retries during the --repo identity check; run deferred, nothing posted
|
|
41
|
+
GH_AUTH: "gh.auth", // forge#4662: gh answered HTTP 401 and one token refresh did not fix it; run deferred, a rerun resumes
|
|
40
42
|
MERGE_TRANSIENT: "merge.transient",
|
|
41
43
|
PUSH_TRANSIENT: "push.transient",
|
|
42
44
|
CLI_IDLE: "cli.idle",
|
|
43
45
|
RUN_INTERRUPTED: "run.interrupted", // forge#4179: SIGTERM/SIGINT on a core run; teardown ran from the ledger, lane requeued
|
|
44
46
|
PATH_SWITCH_CONFLICT: "path.switch_conflict",
|
|
45
47
|
ENGINE_ERROR: "engine.error",
|
|
48
|
+
ENGINE_VALIDATE_RESULT_LOST: "engine.validate_result_lost", // forge#4708: a validate result recorded before a CI fix round is missing after it and could not be carried forward
|
|
46
49
|
INFRA_DISK_QUOTA: "infra.disk_quota",
|
|
47
50
|
INFRA_CLI_SPAWN_TRANSIENT: "infra.cli_spawn_transient",
|
|
48
51
|
CLI_TIMEOUT: "cli.timeout",
|
|
@@ -51,9 +54,12 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
51
54
|
ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
|
|
52
55
|
ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
|
|
53
56
|
ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
|
|
57
|
+
ENV_LANE_LIMIT: "env.lane_limit", // forge#4690: the lane hit its own scope limit (TasksMax or MemoryMax) and was stopped; an environment error, never an engine-error, so the breaker ignores it
|
|
54
58
|
ENV_CAPABILITY_REMOTE: "env.capability_remote", // forge#4542: a remote sandbox lane blocked only on a host-only capability (SOPS key, ops credentials, prod monitor SSH) the dispatching host has; recovered locally
|
|
55
59
|
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
|
|
56
60
|
SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
|
|
61
|
+
SANDBOX_AUTH: "sandbox.auth", // forge#4685: the sandbox provider refused or lacked the credential (HTTP 401/403, no token); deferred, never needs-human
|
|
62
|
+
SANDBOX_UNREACHABLE: "sandbox.unreachable", // forge#4672: the sandbox provider stayed unreachable past execution.unreachable_window_min; deferred, the sandbox is kept for adopt
|
|
57
63
|
LEASE_HELD: "lease.held",
|
|
58
64
|
DEPENDENCY_OPEN: "dependency.open",
|
|
59
65
|
DEPENDENCY_UNREADABLE: "dependency.unreadable",
|
|
@@ -79,11 +85,13 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
79
85
|
REVIEW_INCONSISTENT: "review.inconsistent",
|
|
80
86
|
CONFORMANCE_OMISSION: "conformance.omission",
|
|
81
87
|
CONFORMANCE_UNIMPLEMENTED: "conformance.unimplemented",
|
|
88
|
+
CONFORMANCE_E2E_UNVERIFIED: "conformance.e2e_unverified",
|
|
82
89
|
CONFORMANCE_SCOPE_CREEP: "conformance.scope_creep",
|
|
83
90
|
CONFORMANCE_UNTRACKED_DEFERRAL: "conformance.untracked_deferral",
|
|
84
91
|
CONFORMANCE_UNJUSTIFIED_DEFERRAL: "conformance.unjustified_deferral",
|
|
85
92
|
CONFORMANCE_PRESERVE_VIOLATED: "conformance.preserve_violated",
|
|
86
93
|
CONFORMANCE_REGRESSION: "conformance.regression",
|
|
94
|
+
CONFORMANCE_GUARD_BROKEN: "conformance.guard_broken", // forge#4693: the base version of a merged fix's guarding test fails against the lane's tree
|
|
87
95
|
STRUCTURE_UNDECLARED_EDGE: "structure.undeclared_edge", // forge#4159: a workspace package.json edge the contract does not name
|
|
88
96
|
DOCS_STALE: "docs.stale", // the target repo's devdocs freshness rule still fails after one builder resume
|
|
89
97
|
MAINTENANCE_HOTSPOT_GROWTH: "maintenance.hotspot_growth", // forge#4195: an over-threshold file grew past growth_lines without hotspot-exempt
|
|
@@ -196,7 +204,17 @@ export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
|
196
204
|
* edit (`issueEdited`), `ci-pending` on a conflicting PR (`conflicting`), and the non-human `budget-exceeded` turn-overrun end (forge#4505). Human: `builder.blocked`. Anything else
|
|
197
205
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
198
206
|
*/
|
|
199
|
-
export function classifyNeedsHuman(row) {
|
|
207
|
+
export function classifyNeedsHuman(row, { parked = false } = {}) {
|
|
208
|
+
const cls = classifyNeedsHumanBase(row);
|
|
209
|
+
// forge#4656: opt-in `parked` class: a needs-human row ended on a mechanical terminal code (not a human block, not recoverable).
|
|
210
|
+
return parked && cls === "terminal" && isTerminalCodeRow(row) ? "parked" : cls;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isTerminalCodeRow(row) {
|
|
214
|
+
return (row.outcome ?? row.terminalReason) === "needs-human" && typeof row.code === "string" && row.code !== "" && row.code !== UNCLASSIFIED;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function classifyNeedsHumanBase(row) {
|
|
200
218
|
if (!row || typeof row !== "object") return "terminal";
|
|
201
219
|
const outcome = row.outcome ?? row.terminalReason;
|
|
202
220
|
if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
|
|
@@ -211,3 +229,23 @@ export function classifyNeedsHuman(row) {
|
|
|
211
229
|
export function recoveryCodeOf(row) {
|
|
212
230
|
return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
|
|
213
231
|
}
|
|
232
|
+
|
|
233
|
+
/** forge#4656: the label of a human-owned wait (an owner and an unblock condition are required in its comment). */
|
|
234
|
+
export const NEEDS_HUMAN_EXTERNAL_LABEL = "needs-human:external";
|
|
235
|
+
/** forge#4656: the single remediation queue (`/work-on:remediate`) a parked issue moves to once recovery attempts run out. */
|
|
236
|
+
export const REMEDIATION_QUEUE_LABEL = "remediation-queue";
|
|
237
|
+
/** forge#4656: `parked:<terminal-code>` label of a mechanical park. */
|
|
238
|
+
export function parkedLabel(code) {
|
|
239
|
+
return `parked:${String(code ?? UNCLASSIFIED).trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").slice(0, 40) || UNCLASSIFIED}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* forge#4656 migration: classify a legacy bare `needs-human` issue from its last FORGE terminal annotation text.
|
|
244
|
+
* `{ kind: "parked", code }` when the text names a mechanical terminal code, else `{ kind: "external" }` (the default).
|
|
245
|
+
*/
|
|
246
|
+
export function classifyLegacyNeedsHuman(annotation) {
|
|
247
|
+
const text = String(annotation ?? "");
|
|
248
|
+
const m = /\b(ci\.red|ci\.timeout|base\.conflict|review_incomplete|builder\.block_untyped|conformance\.[a-z_]+|contract\.invalid|env\.[a-z_]+)\b/i.exec(text);
|
|
249
|
+
if (m) return { kind: "parked", code: m[1].toLowerCase() };
|
|
250
|
+
return { kind: "external" };
|
|
251
|
+
}
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { PHASE_MARKERS } from "../../packages/protocol/src/phases.js";
|
|
15
15
|
// forge#3833: closed outcome-code enum; every code below comes from structured evidence, never `detail` prose.
|
|
16
16
|
import { OUTCOME_CODES as OC } from "./outcome-codes.mjs";
|
|
17
|
+
import { parseState } from "./state.mjs";
|
|
17
18
|
|
|
18
19
|
// forge#2261: "engine-error" is a distinct terminal reason for engine/tool-level
|
|
19
20
|
// failures (e.g. an exhausted retry loop where the runner itself never once
|
|
@@ -355,7 +356,16 @@ export function parseDependsOn(body, issue = null) {
|
|
|
355
356
|
* when the issue or a declared dependency cannot be read (fail closed: the caller refuses the run).
|
|
356
357
|
* An empty read of the issue itself means "no body to trust" and yields `null`.
|
|
357
358
|
*/
|
|
358
|
-
export async function openDependency(issue, io) {
|
|
359
|
+
export async function openDependency(issue, io, { lane = null } = {}) {
|
|
360
|
+
const satisfied = [];
|
|
361
|
+
const prsOnLane = lane ? laneMergedPrs(lane, io) : null;
|
|
362
|
+
/** An open dependency whose change is already on `lane` is satisfied (records the merge commit). */
|
|
363
|
+
const onLane = async (n, rec = null) => {
|
|
364
|
+
if (!prsOnLane) return false;
|
|
365
|
+
const hit = await dependencyOnLane(n, lane, await prsOnLane, io, rec);
|
|
366
|
+
if (hit) satisfied.push({ issue: n, ...hit });
|
|
367
|
+
return !!hit;
|
|
368
|
+
};
|
|
359
369
|
let rec;
|
|
360
370
|
try { rec = await readIssueRecord(issue, io); } catch (e) {
|
|
361
371
|
return { unreadable: true, detail: `issue ${issue} could not be read for its **Depends on** line: ${e?.message || e}` };
|
|
@@ -374,21 +384,71 @@ export async function openDependency(issue, io) {
|
|
|
374
384
|
// when all close). No usable trail (none, untrusted, revoked, unreadable) keeps the parent itself open (fail closed).
|
|
375
385
|
if (isDecomposedRecord(d) && (st === "open" || st === "closed")) {
|
|
376
386
|
const subs = await decomposedSubissues(dep, io);
|
|
377
|
-
if (!subs) { open.push(dep); continue; }
|
|
387
|
+
if (!subs) { if (!(st === "open" && await onLane(dep, d))) open.push(dep); continue; }
|
|
378
388
|
for (const s of subs) {
|
|
379
389
|
let sr;
|
|
380
390
|
try { sr = await readIssueRecord(s, io); } catch (e) {
|
|
381
391
|
return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} could not be read: ${e?.message || e}` };
|
|
382
392
|
}
|
|
383
393
|
const ss = typeof sr?.state === "string" ? sr.state.toLowerCase() : "";
|
|
384
|
-
if (ss === "open") open.push(s);
|
|
394
|
+
if (ss === "open") { if (!(await onLane(s, sr))) open.push(s); }
|
|
385
395
|
else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
|
|
386
396
|
}
|
|
387
397
|
}
|
|
388
|
-
else if (st === "open") open.push(dep);
|
|
398
|
+
else if (st === "open") { if (!(await onLane(dep, d))) open.push(dep); }
|
|
389
399
|
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
390
400
|
}
|
|
391
|
-
|
|
401
|
+
const still = [...new Set(open)].filter((n) => n !== Number(issue));
|
|
402
|
+
if (still.length) return { open: still, ...(satisfied.length ? { satisfied } : {}) };
|
|
403
|
+
return satisfied.length ? { satisfied } : null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Merged PRs on base `lane` (null when the list cannot be read: fail closed, nothing is satisfied). */
|
|
407
|
+
export async function laneMergedPrs(lane, io) {
|
|
408
|
+
try {
|
|
409
|
+
const out = String((await io.gh(["pr", "list", "--state", "merged", "--base", lane, "--limit", "200", "--json", "number,mergeCommit,closingIssuesReferences,title,body"])) ?? "").trim();
|
|
410
|
+
const j = out ? JSON.parse(out) : [];
|
|
411
|
+
return Array.isArray(j) ? j : null;
|
|
412
|
+
} catch { return null; }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* #4711: the `mergedTo` {lane, pr, sha} fact on a trusted issue record's FORGE:STATE, when it names `lane`, the PR reads MERGED with that
|
|
417
|
+
* merge commit, and the commit is an ancestor of `origin/<lane>` ({ pr, sha }), else null. Any lookup error is "not on the lane".
|
|
418
|
+
*/
|
|
419
|
+
async function mergedToByState(lane, io, rec) {
|
|
420
|
+
if (!rec || typeof rec.body !== "string" || !isTrustedComment(rec)) return null;
|
|
421
|
+
const m = parseState(rec.body)?.mergedTo;
|
|
422
|
+
if (!m || m.lane !== lane || !Number.isInteger(m.pr) || typeof m.sha !== "string" || !/^[0-9a-f]{7,64}$/i.test(m.sha)) return null;
|
|
423
|
+
try {
|
|
424
|
+
const pr = JSON.parse(String((await io.gh(["pr", "view", String(m.pr), "--json", "state,mergeCommit,baseRefName"])) ?? "{}"));
|
|
425
|
+
if (String(pr.state).toUpperCase() !== "MERGED" || pr.mergeCommit?.oid !== m.sha || (pr.baseRefName && pr.baseRefName !== lane)) return null;
|
|
426
|
+
try { await io.git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
427
|
+
await io.git(["merge-base", "--is-ancestor", m.sha, `origin/${lane}`]);
|
|
428
|
+
return { pr: m.pr, sha: m.sha };
|
|
429
|
+
} catch { return null; }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* #4686: the PR that closes or references issue `n` and whose merge commit is an ancestor of `origin/<lane>`
|
|
434
|
+
* ({ pr, sha }), else null. Judged by commit ancestry, not issue state; any lookup error is "not on the lane".
|
|
435
|
+
*/
|
|
436
|
+
export async function dependencyOnLane(n, lane, prs, io, rec = null) {
|
|
437
|
+
const stated = await mergedToByState(lane, io, rec);
|
|
438
|
+
if (stated) return stated;
|
|
439
|
+
if (!Array.isArray(prs)) return null;
|
|
440
|
+
const ref = new RegExp(`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?|part of)[ \\t:]+(?:[\\w.-]+/[\\w.-]+)?#${n}(?!\\d)`, "i");
|
|
441
|
+
const cands = prs.filter((p) => typeof p?.mergeCommit?.oid === "string" && p.mergeCommit.oid
|
|
442
|
+
&& ((p.closingIssuesReferences ?? []).some((r) => Number(r?.number) === n) || ref.test(`${p.title ?? ""}\n${p.body ?? ""}`)));
|
|
443
|
+
if (!cands.length) return null;
|
|
444
|
+
try { await io.git(["fetch", "origin", lane]); } catch { /* offline: use the local ref */ }
|
|
445
|
+
for (const p of cands) {
|
|
446
|
+
try {
|
|
447
|
+
await io.git(["merge-base", "--is-ancestor", p.mergeCommit.oid, `origin/${lane}`]);
|
|
448
|
+
return { pr: p.number, sha: p.mergeCommit.oid };
|
|
449
|
+
} catch { /* not on the lane */ }
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
392
452
|
}
|
|
393
453
|
|
|
394
454
|
/**
|
|
@@ -63,7 +63,7 @@ export function mapRecord(rec, wallMs) {
|
|
|
63
63
|
}) };
|
|
64
64
|
}
|
|
65
65
|
if (rec.event === "RUN_TERMINAL") {
|
|
66
|
-
return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs,
|
|
66
|
+
return { event: "run_terminal", phase: null, ...prOf(rec), payload: compact({ reason: rec.reason, code: rec.code, wall_ms: wallMs, guard_added_ms: rec.guard_added_ms, // #4705
|
|
67
67
|
milestone: rec.milestone, conformance_mode: rec.conformance_mode, shadow_would: rec.shadow_would,
|
|
68
68
|
builder_tier: rec.builder_tier, builder_class: rec.builder_class, builder_model: rec.builder_model, // #4394: the model per merge
|
|
69
69
|
builder_escalated: typeof rec.builder_escalated === "boolean" ? Number(rec.builder_escalated) : undefined,
|
|
@@ -80,6 +80,10 @@ describe("engine telemetry seam", () => {
|
|
|
80
80
|
assert.deepEqual(p2, { reason: "merged", wall_ms: 0, milestone: "M", conformance_mode: "shadow", shadow_would: "conforms" });
|
|
81
81
|
assert.equal(mapRecord({ event: "RUN_TERMINAL", reason: "merged", pr: 0 }, 1).pr, undefined, "no pr 0 in ctx");
|
|
82
82
|
});
|
|
83
|
+
it("#4705: RUN_TERMINAL carries the guard gate's added wall time beside wall_ms", () => {
|
|
84
|
+
const m = mapRecord({ event: "RUN_TERMINAL", reason: "merged", wall_ms: 1000, guard_added_ms: 40 }, 1);
|
|
85
|
+
assert.equal(m.payload.guard_added_ms, 40);
|
|
86
|
+
});
|
|
83
87
|
it("forge#4255: SANDBOX_USAGE maps to sandbox_usage with provider, minutes and cost", async () => {
|
|
84
88
|
const { calls, emit } = harness();
|
|
85
89
|
await emit({ event: "SANDBOX_USAGE", issue: 7, provider: "container", sandbox_id: "fdsbx-b-7", minutes: 12.5, cost_usd: 0.1234567891, batch: "b1" });
|