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,276 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Milestone integration runs (forge#4667). No LLM anywhere. Under "fast lane gate, heavy ship gate" the whole e2e suite
|
|
5
|
+
* only runs at the ship PR; this runs it (report-only) on every active milestone branch on a schedule or every N lane
|
|
6
|
+
* merges, so integration failures surface while the milestone is still open.
|
|
7
|
+
*
|
|
8
|
+
* - Config: forge.yaml `ci:` / `milestone_integration:` block (the parser flattens its keys under `ci`); inert without `workflow`.
|
|
9
|
+
* - State: one `<!-- FORGE:INTEGRATION {json} -->` block in the GitHub milestone description (GitHub is the register):
|
|
10
|
+
* lastRun, lastGreen {sha, at, url}, redStreak, mergesSince, mergedPrs, issue.
|
|
11
|
+
* - Red run: ONE `integration-red` issue per milestone (marker FORGE:INTEGRATION:<slug>), created or its body updated,
|
|
12
|
+
* with the failing specs and the merge range. Green: the issue is commented and closed, the streak resets.
|
|
13
|
+
* - block_admission_after: the orchestrate seam `holds` refuses new lanes of the milestone while the red streak is at
|
|
14
|
+
* or over N, until a green run.
|
|
15
|
+
* The workflow is dispatched on the milestone branch and awaited through runCiVerify (verify.mjs).
|
|
16
|
+
*/
|
|
17
|
+
import { milestoneSlug, readBranches, DEFAULT_FEATURE_PATTERN } from "./lane.mjs";
|
|
18
|
+
import { readMilestone } from "./drift.mjs";
|
|
19
|
+
import { runCiVerify } from "./verify.mjs";
|
|
20
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const STATE_MARKER = "FORGE:INTEGRATION";
|
|
25
|
+
export const RED_LABEL = "integration-red";
|
|
26
|
+
const STATE_RE = /<!-- FORGE:INTEGRATION (\{[\s\S]*?\}) -->/;
|
|
27
|
+
const MAX_PRS = 100;
|
|
28
|
+
|
|
29
|
+
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
30
|
+
const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
|
|
31
|
+
const posInt = (v) => { const n = Number(v); return str(v) !== "" && Number.isInteger(n) && n > 0 ? n : null; };
|
|
32
|
+
const short = (s) => String(s ?? "").slice(0, 7);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* forge.yaml `ci` section → {workflow, project, projectInput, intervalHours, everyMerges, blockAdmissionAfter, maxMinutes}
|
|
36
|
+
* or null when no valid `workflow` is configured (the feature is off: nothing runs, files or holds).
|
|
37
|
+
*/
|
|
38
|
+
export function parseIntegration(sec) {
|
|
39
|
+
const s = sec && typeof sec === "object" ? sec : {};
|
|
40
|
+
const workflow = /^[A-Za-z0-9._/-]+$/.test(str(s.workflow)) ? str(s.workflow) : null;
|
|
41
|
+
if (!workflow) return null;
|
|
42
|
+
const hours = Number(s.interval_hours);
|
|
43
|
+
return {
|
|
44
|
+
workflow,
|
|
45
|
+
project: str(s.project) || null,
|
|
46
|
+
projectInput: /^[A-Za-z_][A-Za-z0-9_-]*$/.test(str(s.project_input)) ? str(s.project_input) : "project",
|
|
47
|
+
intervalHours: str(s.interval_hours) !== "" && Number.isFinite(hours) && hours > 0 ? hours : 24,
|
|
48
|
+
everyMerges: posInt(s.every_merges),
|
|
49
|
+
blockAdmissionAfter: posInt(s.block_admission_after),
|
|
50
|
+
maxMinutes: posInt(s.max_minutes) ?? 360,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** forge.yaml `ci:` of the checkout through parseIntegration; null when absent or unreadable. */
|
|
55
|
+
export function readIntegrationConfig(cwd = process.cwd()) {
|
|
56
|
+
try { return parseIntegration(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.ci); } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const emptyState = () => ({ v: 1, lastRun: null, lastGreen: null, redStreak: 0, mergesSince: 0, mergedPrs: [], issue: null });
|
|
60
|
+
|
|
61
|
+
/** The FORGE:INTEGRATION state in a milestone description (defaults filled), or null when there is no block. */
|
|
62
|
+
export function parseIntegrationState(description) {
|
|
63
|
+
const m = STATE_RE.exec(String(description ?? ""));
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) ? { ...emptyState(), ...s } : null; } catch { return null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `description` with its FORGE:INTEGRATION block replaced (or appended); the text around it is unchanged. */
|
|
69
|
+
export function renderState(description, state) {
|
|
70
|
+
const d = String(description ?? "");
|
|
71
|
+
const block = `<!-- ${STATE_MARKER} ${JSON.stringify(state).replace(/>/g, "\\u003e")} -->`;
|
|
72
|
+
if (STATE_RE.test(d)) return d.replace(STATE_RE, () => block);
|
|
73
|
+
return d ? `${d}\n\n${block}` : block;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Due when never run, `intervalHours` elapsed, or `everyMerges` lane merges since the last run. */
|
|
77
|
+
export function isDue(state, cfg, nowMs) {
|
|
78
|
+
const s = state ?? emptyState();
|
|
79
|
+
if (!s.lastRun?.at) return { due: true, reason: "never run" };
|
|
80
|
+
if (cfg.everyMerges && s.mergesSince >= cfg.everyMerges) return { due: true, reason: `${s.mergesSince} lane merge(s) since the last run` };
|
|
81
|
+
const age = nowMs - Date.parse(s.lastRun.at);
|
|
82
|
+
if (!Number.isFinite(age) || age >= cfg.intervalHours * 3600000) return { due: true, reason: `last run ${Number.isFinite(age) ? `${Math.round(age / 3600000)}h` : "unknown"} ago` };
|
|
83
|
+
return { due: false, reason: "not due" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const SPEC_RE = /[\w@./-]+\.(?:e2e-spec|e2e|spec|test)\.[cm]?[jt]sx?/g;
|
|
87
|
+
/** Spec files named in the failed-job logs (sorted, at most 50). */
|
|
88
|
+
export function failingSpecs(failures) {
|
|
89
|
+
const out = new Set();
|
|
90
|
+
for (const f of arr(failures)) for (const m of String(f?.log ?? "").match(SPEC_RE) ?? []) out.add(m.replace(/^\.\//, ""));
|
|
91
|
+
return [...out].sort().slice(0, 50);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const redMarker = (slug) => `${STATE_MARKER}:${slug}`;
|
|
95
|
+
|
|
96
|
+
/** Title and body of the integration-red issue. */
|
|
97
|
+
export function redIssue({ slug, milestone, head, state, failures, specs, runUrl, repo, at }) {
|
|
98
|
+
const g = state.lastGreen;
|
|
99
|
+
const range = g?.sha ? `\`${short(g.sha)}..${short(head)}\` ([compare](https://github.com/${repo}/compare/${g.sha}...${head}))` : `no green run yet; up to \`${short(head)}\``;
|
|
100
|
+
const prs = arr(state.mergedPrs);
|
|
101
|
+
const body = [
|
|
102
|
+
`The scheduled full-suite integration run on \`milestone/${slug}\` is red (${state.redStreak} consecutive red run${state.redStreak === 1 ? "" : "s"}). The run is report-only.`, "",
|
|
103
|
+
`- Run: ${runUrl ?? "n/a"} at \`${short(head)}\` (${at})`,
|
|
104
|
+
`- Merge range since the last green run: ${range}`,
|
|
105
|
+
`- Lane PRs merged in that range: ${prs.length ? prs.map((p) => `#${p}`).join(", ") : "none recorded"}`,
|
|
106
|
+
`- Failing jobs: ${arr(failures).map((f) => f.job).filter(Boolean).join(", ") || "n/a"}`, "",
|
|
107
|
+
"### Failing specs", "", ...(specs.length ? specs.map((s) => `- \`${s}\``) : ["- none named in the failed-job logs; see the run"]), "",
|
|
108
|
+
"This issue is a ship-gate risk for the milestone and closes itself on the next green integration run.", "",
|
|
109
|
+
`<!-- ${redMarker(slug)} -->`,
|
|
110
|
+
].join("\n");
|
|
111
|
+
return { title: `integration-red(milestone/${slug}): full suite failing at ${short(head)}`, body, marker: redMarker(slug), milestone };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Open issue of the milestone carrying `marker`: its number, or null. */
|
|
115
|
+
async function findRed({ gh, repo, milestone, marker }) {
|
|
116
|
+
const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", milestone, "--state", "open", "--limit", "500", "--json", "number,body"])) || "[]");
|
|
117
|
+
const hit = arr(open).find((x) => String(x?.body ?? "").includes(`<!-- ${marker} -->`));
|
|
118
|
+
return hit ? Number(hit.number) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Create the one integration-red issue, or replace the body of the open one. Resolves its number. */
|
|
122
|
+
export async function fileOrUpdateRed({ gh, repo, issue }) {
|
|
123
|
+
const n = await findRed({ gh, repo, milestone: issue.milestone, marker: issue.marker });
|
|
124
|
+
if (n) { await gh(["issue", "edit", String(n), "-R", repo, "--title", issue.title, "--body", issue.body]); return { number: n, created: false }; }
|
|
125
|
+
const base = ["issue", "create", "-R", repo, "--title", issue.title, "--body", issue.body, "--milestone", issue.milestone];
|
|
126
|
+
let out;
|
|
127
|
+
try { out = String(await gh([...base, "--label", RED_LABEL])); } catch { out = String(await gh(base)); } // the label may not exist in the repo yet
|
|
128
|
+
return { number: Number(/\/issues\/(\d+)/.exec(out)?.[1]) || null, created: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Green: comment on and close the open integration-red issue of the milestone, if any. */
|
|
132
|
+
export async function closeRed({ gh, repo, milestone, slug, head }) {
|
|
133
|
+
const n = await findRed({ gh, repo, milestone, marker: redMarker(slug) });
|
|
134
|
+
if (n) await gh(["issue", "close", String(n), "-R", repo, "--comment", `Integration run green at \`${short(head)}\`; closing.`]);
|
|
135
|
+
return n;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const branchOf = (pattern, title) => { const slug = milestoneSlug(title); return slug ? { slug, branch: String(pattern || DEFAULT_FEATURE_PATTERN).replace("{slug}", slug) } : null; };
|
|
139
|
+
|
|
140
|
+
/** Head SHA of `branch`, or null when it does not exist. */
|
|
141
|
+
async function branchHead(gh, repo, branch) {
|
|
142
|
+
try { return str(await gh(["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`, "--jq", ".commit.sha"])) || null; } catch { return null; }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One integration run of one milestone: dispatch, wait, record. Resolves {title, ran, result, detail, state}. `ran` is
|
|
147
|
+
* false when the milestone is skipped (no branch, not due); a timeout or unavailable run records nothing and retries next time.
|
|
148
|
+
*/
|
|
149
|
+
export async function runMilestone({ io, repo, ms, cfg, pattern, nowFn = () => Date.now(), sleep, random, force = false, onPoll }) {
|
|
150
|
+
const gh = io.gh, b = branchOf(pattern, ms.title);
|
|
151
|
+
if (!b) return { title: ms.title, ran: false, detail: "milestone gives an empty branch slug" };
|
|
152
|
+
const head = await branchHead(gh, repo, b.branch);
|
|
153
|
+
if (!head) return { title: ms.title, ran: false, detail: `branch ${b.branch} not found` };
|
|
154
|
+
const before = parseIntegrationState(ms.description) ?? emptyState();
|
|
155
|
+
const due = isDue(before, cfg, nowFn());
|
|
156
|
+
if (!force && !due.due) return { title: ms.title, ran: false, detail: due.reason, state: before };
|
|
157
|
+
const v = await runCiVerify({ io, branch: b.branch, head, changedFiles: [cfg.project ?? "integration"], verify: { workflow: cfg.workflow, pathsInput: cfg.project ? cfg.projectInput : null, skipPaths: [] },
|
|
158
|
+
ciMaxMinutes: cfg.maxMinutes, now: nowFn, sleep, random, ...(onPoll ? { onPoll } : {}) });
|
|
159
|
+
if (v.result !== "pass" && v.result !== "fail") return { title: ms.title, ran: true, result: v.result, detail: v.detail, state: before };
|
|
160
|
+
const at = new Date(nowFn()).toISOString();
|
|
161
|
+
const state = { ...before, lastRun: { sha: head, at, result: v.result, url: v.runUrl ?? null }, mergesSince: 0 };
|
|
162
|
+
if (v.result === "pass") {
|
|
163
|
+
state.lastGreen = { sha: head, at, url: v.runUrl ?? null }; state.redStreak = 0; state.mergedPrs = []; state.issue = null;
|
|
164
|
+
await closeRed({ gh, repo, milestone: ms.title, slug: b.slug, head });
|
|
165
|
+
} else {
|
|
166
|
+
state.redStreak = (before.redStreak ?? 0) + 1;
|
|
167
|
+
const specs = failingSpecs(v.failures);
|
|
168
|
+
const filed = await fileOrUpdateRed({ gh, repo, issue: redIssue({ slug: b.slug, milestone: ms.title, head, state: { ...state, blockAdmissionAfter: cfg.blockAdmissionAfter }, failures: v.failures, specs, runUrl: v.runUrl, repo, at }) });
|
|
169
|
+
state.issue = filed.number;
|
|
170
|
+
}
|
|
171
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderState(ms.description, state)}`]);
|
|
172
|
+
return { title: ms.title, ran: true, result: v.result, detail: v.detail, state };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Every open milestone → {number, title, description}. */
|
|
176
|
+
export async function listOpenMilestones({ gh, repo }) {
|
|
177
|
+
return arr(JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=open&per_page=100`])) || "[]")).map((m) => ({ number: m.number, title: m.title, description: m.description ?? "" }));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Run every due active milestone (optionally only `only`, a title). Sequential: one suite at a time. Resolves the per-milestone results. */
|
|
181
|
+
export async function runIntegration({ io, repo, cfg, pattern, only = null, ...rest }) {
|
|
182
|
+
if (!cfg) return [];
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const ms of await listOpenMilestones({ gh: io.gh, repo })) {
|
|
185
|
+
if (only && ms.title !== only) continue;
|
|
186
|
+
try { out.push(await runMilestone({ io, repo, ms, cfg, pattern, ...rest })); } catch (e) { out.push({ title: ms.title, ran: false, detail: `failed: ${String(e?.message ?? e).slice(0, 200)}` }); }
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The orchestrate seam: `{ onMerged(issue, row, result), holds(issue, {lane}), prime(), settle() }`, or null when `cfg` is null.
|
|
193
|
+
* onMerged counts the merge into the milestone state and, at `every_merges`, starts a run in the background (never in the
|
|
194
|
+
* lane's slot); `settle()` waits for those. `prime()` loads the red streaks `holds` reads (synchronously) from the milestones.
|
|
195
|
+
*/
|
|
196
|
+
export function makeIntegration({ repo, io, cfg, pattern = DEFAULT_FEATURE_PATTERN, nowFn = () => Date.now(), sleep, random, onEvent = () => {} }) {
|
|
197
|
+
if (!cfg) return null;
|
|
198
|
+
const streaks = new Map(); // lane -> consecutive red runs
|
|
199
|
+
const running = new Set();
|
|
200
|
+
const pending = new Set();
|
|
201
|
+
let chain = Promise.resolve();
|
|
202
|
+
const lane = (title) => { const b = branchOf(pattern, title); return b ? b.branch : null; };
|
|
203
|
+
const note = (ms, state) => { const l = lane(ms.title); if (l) streaks.set(l, state?.redStreak ?? 0); };
|
|
204
|
+
|
|
205
|
+
async function count(issue, result) {
|
|
206
|
+
const title = result?.milestone;
|
|
207
|
+
if (!title) return;
|
|
208
|
+
const ms = await readMilestone({ gh: io.gh, repo, title });
|
|
209
|
+
if (!ms) return;
|
|
210
|
+
const full = { ...ms, title };
|
|
211
|
+
const s = parseIntegrationState(ms.description) ?? emptyState();
|
|
212
|
+
const pr = result.pr ?? null;
|
|
213
|
+
const next = { ...s, mergesSince: (s.mergesSince ?? 0) + 1, mergedPrs: pr != null && !s.mergedPrs.includes(pr) ? [...s.mergedPrs, pr].slice(-MAX_PRS) : s.mergedPrs };
|
|
214
|
+
const desc = renderState(ms.description, next);
|
|
215
|
+
await io.gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${desc}`]);
|
|
216
|
+
note(full, next);
|
|
217
|
+
if (!cfg.everyMerges || next.mergesSince < cfg.everyMerges || running.has(title)) return;
|
|
218
|
+
running.add(title);
|
|
219
|
+
const p = runMilestone({ io, repo, ms: { ...full, description: desc }, cfg, pattern, nowFn, sleep, random })
|
|
220
|
+
.then((r) => { if (r.state) note(full, r.state); onEvent({ issue, detail: `integration: ${title} ${r.result ?? r.detail}` }); })
|
|
221
|
+
.catch((e) => onEvent({ issue, detail: `integration run failed: ${String(e?.message ?? e).slice(0, 200)}` }))
|
|
222
|
+
.finally(() => { running.delete(title); pending.delete(p); });
|
|
223
|
+
pending.add(p);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
onMerged(issue, row, result) {
|
|
228
|
+
const p = chain.then(() => count(issue, result));
|
|
229
|
+
chain = p.catch(() => {});
|
|
230
|
+
return p;
|
|
231
|
+
},
|
|
232
|
+
holds(issue, { lane: l } = {}) {
|
|
233
|
+
if (!cfg.blockAdmissionAfter) return null;
|
|
234
|
+
const n = streaks.get(String(l ?? ""));
|
|
235
|
+
return n >= cfg.blockAdmissionAfter ? `${n} consecutive red integration runs on ${l}` : null;
|
|
236
|
+
},
|
|
237
|
+
async prime() {
|
|
238
|
+
for (const ms of await listOpenMilestones({ gh: io.gh, repo })) note(ms, parseIntegrationState(ms.description));
|
|
239
|
+
},
|
|
240
|
+
async settle() { await chain; while (pending.size) await Promise.allSettled([...pending]); },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `forgedock integration run [--repo owner/repo] [--milestone TITLE] [--force]`: one pass over the active milestones, for
|
|
246
|
+
* cron or a GitHub Actions schedule. Prints one line per milestone. Resolves the exit code (1 when a run was red).
|
|
247
|
+
* `forgedock integration status [--repo owner/repo]`: the last green full-suite run of each active milestone (those with
|
|
248
|
+
* a state block, or all when `ci.workflow` is set), for /pipeline-status.
|
|
249
|
+
*/
|
|
250
|
+
export async function runIntegrationCli(argv, deps = {}) {
|
|
251
|
+
const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
|
|
252
|
+
if (argv[0] === "status") {
|
|
253
|
+
const repo = val("--repo") ?? deps.repo;
|
|
254
|
+
if (!repo) throw new Error("integration needs the repo: pass --repo owner/repo");
|
|
255
|
+
const io = deps.io ?? (await import("./effects.mjs")).makeLeanIo({ repo });
|
|
256
|
+
const on = deps.cfg !== undefined ? !!deps.cfg : !!readIntegrationConfig(deps.cwd ?? process.cwd());
|
|
257
|
+
for (const ms of await listOpenMilestones({ gh: io.gh, repo })) {
|
|
258
|
+
const s = parseIntegrationState(ms.description);
|
|
259
|
+
if (s || on) (deps.print ?? ((t) => console.log(t)))(` integration: ${ms.title}: last green full-suite run ${s?.lastGreen?.at ?? "never"} (red streak ${s?.redStreak ?? 0})`);
|
|
260
|
+
}
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
if (argv[0] !== "run") throw new Error("usage: forgedock integration run|status [--repo owner/repo] [--milestone TITLE] [--force]");
|
|
264
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
265
|
+
const cfg = deps.cfg !== undefined ? deps.cfg : readIntegrationConfig(cwd);
|
|
266
|
+
const print = deps.print ?? ((t) => console.log(t));
|
|
267
|
+
if (!cfg) { print("integration: no ci.workflow configured in forge.yaml; nothing to run"); return 0; }
|
|
268
|
+
const repo = val("--repo") ?? deps.repo;
|
|
269
|
+
if (!repo) throw new Error("integration needs the repo: pass --repo owner/repo");
|
|
270
|
+
const io = deps.io ?? (await import("./effects.mjs")).makeLeanIo({ repo });
|
|
271
|
+
const pattern = deps.pattern ?? readBranches(cwd).featurePattern ?? DEFAULT_FEATURE_PATTERN;
|
|
272
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
273
|
+
const res = await runIntegration({ io, repo, cfg, pattern, only: val("--milestone"), force: argv.includes("--force"), nowFn: deps.now ?? (() => Date.now()), sleep, random: deps.random ?? Math.random });
|
|
274
|
+
for (const r of res) print(`integration: ${r.title}: ${r.ran ? r.result ?? "n/a" : "skipped"}${r.detail ? ` (${r.detail})` : ""}`);
|
|
275
|
+
return res.some((r) => r.result === "fail") ? 1 : 0;
|
|
276
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
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 { parseIntegration, parseIntegrationState, renderState, isDue, failingSpecs, runMilestone, runIntegration, makeIntegration, runIntegrationCli, emptyState } from "./integration.mjs";
|
|
6
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
7
|
+
|
|
8
|
+
const SHA = "a".repeat(40), SHA2 = "b".repeat(40);
|
|
9
|
+
const CFG = parseIntegration({ workflow: "e2e.yml", every_merges: "2", block_admission_after: "2" });
|
|
10
|
+
const NOW = Date.parse("2026-10-27T10:00:00Z");
|
|
11
|
+
|
|
12
|
+
/** One milestone "M One" (branch milestone/m-one), a workflow whose conclusion is `st.conclusion`, and a log of every gh call. */
|
|
13
|
+
function fake({ description = "Ship it.", conclusion = "success", branch = true, open = [] } = {}) {
|
|
14
|
+
const st = { description, conclusion, open: [...open], calls: [], created: [], edited: [], closed: [], head: SHA };
|
|
15
|
+
const gh = async (a) => {
|
|
16
|
+
st.calls.push(a);
|
|
17
|
+
if (a[0] === "api" && /milestones\?state=open/.test(a[1])) return JSON.stringify([{ number: 3, title: "M One", description: st.description }, { number: 4, title: "No Branch", description: "" }]);
|
|
18
|
+
if (a[0] === "api" && /milestones\?state=all/.test(a[1])) return JSON.stringify([{ number: 3, title: "M One", description: st.description }]);
|
|
19
|
+
if (a[0] === "api" && /\/branches\//.test(a[1])) { if (branch && /milestone%2Fm-one/.test(a[1])) return `${st.head}\n`; throw new Error("404"); }
|
|
20
|
+
if (a[0] === "api" && a[1] === "-X" && a[2] === "PATCH") { st.description = a[a.indexOf("-f") + 1].slice("description=".length); return "{}"; }
|
|
21
|
+
if (a[0] === "issue" && a[1] === "list") return JSON.stringify(st.open);
|
|
22
|
+
if (a[0] === "issue" && a[1] === "create") {
|
|
23
|
+
const n = 900 + st.created.length;
|
|
24
|
+
st.created.push({ n, title: a[a.indexOf("--title") + 1], body: a[a.indexOf("--body") + 1], label: a.includes("--label") });
|
|
25
|
+
st.open.push({ number: n, body: a[a.indexOf("--body") + 1] });
|
|
26
|
+
return `https://github.com/o/r/issues/${n}\n`;
|
|
27
|
+
}
|
|
28
|
+
if (a[0] === "issue" && a[1] === "edit") { st.edited.push({ n: a[2], body: a[a.indexOf("--body") + 1] }); st.open = st.open.map((x) => (String(x.number) === a[2] ? { ...x, body: a[a.indexOf("--body") + 1] } : x)); return ""; }
|
|
29
|
+
if (a[0] === "issue" && a[1] === "close") { st.closed.push(a[2]); st.open = st.open.filter((x) => String(x.number) !== a[2]); return ""; }
|
|
30
|
+
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
31
|
+
};
|
|
32
|
+
const io = { gh, dispatchWorkflow: async () => {},
|
|
33
|
+
workflowRuns: async () => [{ databaseId: 5, headSha: st.head, status: "completed", conclusion: st.conclusion, url: "https://run/5", name: "e2e" }],
|
|
34
|
+
failedLogs: async () => "FAIL e2e/cart.spec.ts\nFAIL e2e/login.e2e.ts\n" };
|
|
35
|
+
return { st, io };
|
|
36
|
+
}
|
|
37
|
+
const run = (io, st, extra = {}) => runIntegration({ io, repo: "o/r", cfg: CFG, pattern: "milestone/{slug}", nowFn: () => NOW, sleep: async () => {}, ...extra });
|
|
38
|
+
|
|
39
|
+
describe("config and state", () => {
|
|
40
|
+
it("is off without a workflow and parses the keys", () => {
|
|
41
|
+
assert.equal(parseIntegration({}), null);
|
|
42
|
+
assert.equal(parseIntegration({ workflow: "a b" }), null);
|
|
43
|
+
assert.deepEqual([CFG.workflow, CFG.intervalHours, CFG.everyMerges, CFG.blockAdmissionAfter, CFG.projectInput], ["e2e.yml", 24, 2, 2, "project"]);
|
|
44
|
+
assert.equal(parseIntegration({ workflow: "e.yml", interval_hours: "x", block_admission_after: "0" }).blockAdmissionAfter, null);
|
|
45
|
+
});
|
|
46
|
+
it("round-trips the state block and keeps the description text", () => {
|
|
47
|
+
const d = renderState("Ship it.", { ...emptyState(), redStreak: 3 });
|
|
48
|
+
assert.match(d, /^Ship it\./);
|
|
49
|
+
assert.equal(parseIntegrationState(d).redStreak, 3);
|
|
50
|
+
assert.equal(parseIntegrationState(renderState(d, { ...emptyState(), redStreak: 1 })).redStreak, 1);
|
|
51
|
+
assert.equal(parseIntegrationState("none"), null);
|
|
52
|
+
});
|
|
53
|
+
it("is due when never run, aged, or after N merges", () => {
|
|
54
|
+
assert.equal(isDue(null, CFG, NOW).due, true);
|
|
55
|
+
const s = { ...emptyState(), lastRun: { at: new Date(NOW - 3600000).toISOString() } };
|
|
56
|
+
assert.equal(isDue(s, CFG, NOW).due, false);
|
|
57
|
+
assert.equal(isDue({ ...s, mergesSince: 2 }, CFG, NOW).due, true);
|
|
58
|
+
assert.equal(isDue({ ...s, lastRun: { at: new Date(NOW - 25 * 3600000).toISOString() } }, CFG, NOW).due, true);
|
|
59
|
+
});
|
|
60
|
+
it("names failing specs from logs", () => assert.deepEqual(failingSpecs([{ log: "FAIL e2e/a.spec.ts x e2e/a.spec.ts ./b.test.js" }]), ["b.test.js", "e2e/a.spec.ts"]));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("runIntegration", () => {
|
|
64
|
+
it("skips a milestone without a branch and records a green run", async () => {
|
|
65
|
+
const { st, io } = fake();
|
|
66
|
+
const res = await run(io, st);
|
|
67
|
+
assert.equal(res.find((r) => r.title === "No Branch").ran, false);
|
|
68
|
+
assert.equal(res.find((r) => r.title === "M One").result, "pass");
|
|
69
|
+
const s = parseIntegrationState(st.description);
|
|
70
|
+
assert.equal(s.lastGreen.sha, SHA);
|
|
71
|
+
assert.equal(s.redStreak, 0);
|
|
72
|
+
assert.equal(st.created.length, 0);
|
|
73
|
+
});
|
|
74
|
+
it("a red run files ONE integration-red issue and a second red updates it", async () => {
|
|
75
|
+
const { st, io } = fake({ conclusion: "failure" });
|
|
76
|
+
await run(io, st);
|
|
77
|
+
assert.equal(st.created.length, 1);
|
|
78
|
+
assert.match(st.created[0].title, /integration-red\(milestone\/m-one\)/);
|
|
79
|
+
assert.ok(st.created[0].label);
|
|
80
|
+
assert.match(st.created[0].body, /e2e\/cart\.spec\.ts/);
|
|
81
|
+
assert.match(st.created[0].body, /Merge range/);
|
|
82
|
+
assert.match(st.created[0].body, /<!-- FORGE:INTEGRATION:m-one -->/);
|
|
83
|
+
st.head = SHA2;
|
|
84
|
+
await run(io, st, { force: true });
|
|
85
|
+
assert.equal(st.created.length, 1, "no second issue");
|
|
86
|
+
assert.equal(st.edited.length, 1);
|
|
87
|
+
assert.match(st.edited[0].body, /2 consecutive red runs/);
|
|
88
|
+
assert.equal(parseIntegrationState(st.description).redStreak, 2);
|
|
89
|
+
});
|
|
90
|
+
it("a green run after red closes the issue, records the range start and resets the streak", async () => {
|
|
91
|
+
const { st, io } = fake({ conclusion: "failure" });
|
|
92
|
+
await run(io, st);
|
|
93
|
+
st.conclusion = "success"; st.head = SHA2;
|
|
94
|
+
await run(io, st, { force: true });
|
|
95
|
+
assert.deepEqual(st.closed, ["900"]);
|
|
96
|
+
const s = parseIntegrationState(st.description);
|
|
97
|
+
assert.equal(s.redStreak, 0);
|
|
98
|
+
assert.equal(s.lastGreen.sha, SHA2);
|
|
99
|
+
});
|
|
100
|
+
it("the red issue names the merge range from the last green run", async () => {
|
|
101
|
+
const { st, io } = fake();
|
|
102
|
+
await run(io, st);
|
|
103
|
+
st.conclusion = "failure"; st.head = SHA2;
|
|
104
|
+
await run(io, st, { force: true });
|
|
105
|
+
assert.match(st.created[0].body, new RegExp(`${SHA.slice(0, 7)}\\.\\.${SHA2.slice(0, 7)}`));
|
|
106
|
+
});
|
|
107
|
+
it("is not run again when not due", async () => {
|
|
108
|
+
const { st, io } = fake();
|
|
109
|
+
await run(io, st);
|
|
110
|
+
const again = await run(io, st);
|
|
111
|
+
assert.equal(again.find((r) => r.title === "M One").ran, false);
|
|
112
|
+
});
|
|
113
|
+
it("a timed-out run records nothing", async () => {
|
|
114
|
+
const { st, io } = fake();
|
|
115
|
+
io.workflowRuns = async () => [{ databaseId: 5, headSha: st.head, status: "in_progress", conclusion: null }];
|
|
116
|
+
let t = NOW;
|
|
117
|
+
const r = await runMilestone({ io, repo: "o/r", ms: { number: 3, title: "M One", description: "" }, cfg: { ...CFG, maxMinutes: 1 }, pattern: "milestone/{slug}", nowFn: () => (t += 120000), sleep: async () => {} });
|
|
118
|
+
assert.equal(r.result, "timeout");
|
|
119
|
+
assert.equal(parseIntegrationState(st.description), null);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("makeIntegration seam", () => {
|
|
124
|
+
it("is null when off", () => assert.equal(makeIntegration({ repo: "o/r", io: {}, cfg: null }), null));
|
|
125
|
+
it("holds a lane after N consecutive red runs and releases it on green", async () => {
|
|
126
|
+
const red = { ...emptyState(), redStreak: 2 };
|
|
127
|
+
const { st, io } = fake({ description: renderState("", red) });
|
|
128
|
+
const seam = makeIntegration({ repo: "o/r", io, cfg: CFG, nowFn: () => NOW, sleep: async () => {} });
|
|
129
|
+
await seam.prime();
|
|
130
|
+
assert.match(seam.holds(1, { lane: "milestone/m-one" }), /2 consecutive red integration runs/);
|
|
131
|
+
assert.equal(seam.holds(1, { lane: "staging" }), null);
|
|
132
|
+
st.head = SHA2;
|
|
133
|
+
for (const pr of [11, 12]) await seam.onMerged(1, {}, { milestone: "M One", pr });
|
|
134
|
+
await seam.settle();
|
|
135
|
+
assert.equal(seam.holds(1, { lane: "milestone/m-one" }), null, "green run released the hold");
|
|
136
|
+
assert.equal(parseIntegrationState(st.description).redStreak, 0);
|
|
137
|
+
});
|
|
138
|
+
it("records merged PRs and starts a run only at every_merges", async () => {
|
|
139
|
+
const { st, io } = fake({ conclusion: "failure" });
|
|
140
|
+
const seam = makeIntegration({ repo: "o/r", io, cfg: CFG, nowFn: () => NOW, sleep: async () => {} });
|
|
141
|
+
await seam.onMerged(1, {}, { milestone: "M One", pr: 11 });
|
|
142
|
+
await seam.settle();
|
|
143
|
+
assert.equal(st.created.length, 0);
|
|
144
|
+
assert.deepEqual(parseIntegrationState(st.description).mergedPrs, [11]);
|
|
145
|
+
await seam.onMerged(2, {}, { milestone: "M One", pr: 12 });
|
|
146
|
+
await seam.settle();
|
|
147
|
+
assert.equal(st.created.length, 1);
|
|
148
|
+
assert.match(st.created[0].body, /#11, #12/);
|
|
149
|
+
});
|
|
150
|
+
it("orchestrate ends a held lane `held` and starts nothing while the streak is red", async () => {
|
|
151
|
+
const started = [];
|
|
152
|
+
const integration = { holds: (n, { lane }) => (lane === "milestone/m-one" ? "2 consecutive red integration runs on milestone/m-one" : null) };
|
|
153
|
+
const rows = await orchestrate({ issues: [1], parallel: 1, readBody: async () => "body", resolveLane: async () => ({ lane: "milestone/m-one" }),
|
|
154
|
+
runOne: async (n) => { started.push(n); return { outcome: "merged" }; }, integration, pollMs: 1 });
|
|
155
|
+
assert.deepEqual(started, []);
|
|
156
|
+
assert.equal(rows[0].outcome, "held");
|
|
157
|
+
assert.match(rows[0].detail, /integration: 2 consecutive red/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("runIntegrationCli", () => {
|
|
162
|
+
it("does nothing without ci.workflow", async () => {
|
|
163
|
+
const out = [];
|
|
164
|
+
assert.equal(await runIntegrationCli(["run"], { cfg: null, print: (t) => out.push(t) }), 0);
|
|
165
|
+
assert.match(out[0], /nothing to run/);
|
|
166
|
+
});
|
|
167
|
+
it("prints a line per milestone and exits 1 on a red run", async () => {
|
|
168
|
+
const { st, io } = fake({ conclusion: "failure" });
|
|
169
|
+
const out = [];
|
|
170
|
+
const code = await runIntegrationCli(["run", "--repo", "o/r"], { cfg: CFG, io, pattern: "milestone/{slug}", now: () => NOW, sleep: async () => {}, print: (t) => out.push(t) });
|
|
171
|
+
assert.equal(code, 1);
|
|
172
|
+
assert.ok(out.some((l) => /M One: fail/.test(l)));
|
|
173
|
+
assert.equal(st.created.length, 1);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("integration status", () => {
|
|
178
|
+
it("prints the last green run per milestone", async () => {
|
|
179
|
+
const { st, io } = fake();
|
|
180
|
+
await run(io, st);
|
|
181
|
+
const out = [];
|
|
182
|
+
assert.equal(await runIntegrationCli(["status", "--repo", "o/r"], { io, cfg: CFG, print: (t) => out.push(t) }), 0);
|
|
183
|
+
assert.ok(out.some((l) => /M One: last green full-suite run 2026-10-27T10:00:00\.000Z \(red streak 0\)/.test(l)));
|
|
184
|
+
assert.ok(out.some((l) => /No Branch: last green full-suite run never/.test(l)));
|
|
185
|
+
const off = [];
|
|
186
|
+
await runIntegrationCli(["status", "--repo", "o/r"], { io, cfg: null, print: (t) => off.push(t) });
|
|
187
|
+
assert.equal(off.length, 1, "without ci.workflow only milestones with a state block");
|
|
188
|
+
});
|
|
189
|
+
});
|