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,157 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4696: compounding metrics for `forgedock stats --compounding`: does a repo get safer the longer ForgeDock works on it.
|
|
5
|
+
* Deterministic (0 LLM calls): guards (#4692 FORGE:GUARD comments), `reopened` issue events, git blame, and gate events
|
|
6
|
+
* from the FORGE:TELEMETRY records. The report core is pure; `gatherCompounding` supplies the GitHub and git inputs.
|
|
7
|
+
* reintroduction: a guarded fix whose issue was reopened after the lane closed it (a), or whose merge commit is the blame
|
|
8
|
+
* of the old-side lines of a later guarded fix within 90 days (b; squash and rebase merges, where merge_sha is the commit)
|
|
9
|
+
* reopen_after_close: lane-closed (merged) issues that were reopened afterwards
|
|
10
|
+
* guard / decision counts: gate events guard_run, guard_broken, supersedes_accepted, decision_superseded,
|
|
11
|
+
* decision_violation (#4693, #4695); null (n/a) until a repo has recorded any of that kind
|
|
12
|
+
* Grouped as a rolling window, per release and per lane-age bucket (lane rank = merge order of the repo's lanes).
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { collectGuards } from "../lean/guards.mjs";
|
|
16
|
+
import { parsePages } from "./github-source.mjs";
|
|
17
|
+
|
|
18
|
+
export const REINTRO_DAYS = 90;
|
|
19
|
+
export const DEFAULT_BUCKETS = [100, 500];
|
|
20
|
+
export const DEFAULT_WINDOW_MS = 30 * 86400000;
|
|
21
|
+
export const COUNTED_EVENTS = ["guard_run", "guard_broken", "supersedes_accepted", "decision_superseded", "decision_violation"];
|
|
22
|
+
const DAY = 86400000;
|
|
23
|
+
const ratio = (a, b) => (b ? Math.round((a / b) * 1000) / 1000 : null);
|
|
24
|
+
const ms = (ts) => { const t = Date.parse(ts ?? ""); return Number.isFinite(t) ? t : null; };
|
|
25
|
+
|
|
26
|
+
export const bucketLabel = (rank, edges) => {
|
|
27
|
+
if (rank == null) return "unranked";
|
|
28
|
+
let lo = 1;
|
|
29
|
+
for (const e of edges) { if (rank <= e) return `${lo}-${e}`; lo = e + 1; }
|
|
30
|
+
return `${lo}+`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Guards whose fixed lines a later guarded fix changed within 90 days: [{fix, by}] (fix = earlier guard's issue). Git probes may return null (unresolved). */
|
|
34
|
+
export function blameLineage(guards, git) {
|
|
35
|
+
const t = new Map(), hits = [];
|
|
36
|
+
const time = (g) => { if (!t.has(g.merge_sha)) t.set(g.merge_sha, git.time(g.merge_sha)); return t.get(g.merge_sha); };
|
|
37
|
+
const byCommit = new Map(guards.filter((g) => g.merge_sha).map((g) => [g.merge_sha, g]));
|
|
38
|
+
let unresolved = 0;
|
|
39
|
+
for (const g2 of guards) {
|
|
40
|
+
if (!g2.merge_sha) { unresolved++; continue; }
|
|
41
|
+
const ranges = git.oldRanges(g2.merge_sha);
|
|
42
|
+
if (!ranges) { unresolved++; continue; }
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
for (const r of ranges) for (const sha of git.blame(`${g2.merge_sha}^`, r.file, r.start, r.end) ?? []) {
|
|
45
|
+
const g1 = byCommit.get(sha);
|
|
46
|
+
if (!g1 || g1.issue === g2.issue || seen.has(g1.issue)) continue;
|
|
47
|
+
const a = time(g1), b = time(g2);
|
|
48
|
+
if (a == null || b == null || b < a || b - a > REINTRO_DAYS * 86400) continue;
|
|
49
|
+
seen.add(g1.issue); hits.push({ fix: g1.issue, by: g2.issue });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { hits, unresolved };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Metrics of one group of lane entries and the gate events falling in it. */
|
|
56
|
+
function summarize(entries, events, have, minN) {
|
|
57
|
+
const guarded = entries.filter((e) => e.guarded), lanes = entries.filter((e) => e.merged);
|
|
58
|
+
const count = (k) => (have[k] ? events.filter((e) => e.event === k).length : null);
|
|
59
|
+
return {
|
|
60
|
+
lanes: lanes.length,
|
|
61
|
+
reopen_after_close: { reopened: lanes.filter((e) => e.reopened).length, rate: ratio(lanes.filter((e) => e.reopened).length, lanes.length) },
|
|
62
|
+
reintroduction: { guarded: guarded.length, reintroduced: guarded.filter((e) => e.reintroduced).length, rate: ratio(guarded.filter((e) => e.reintroduced).length, guarded.length) },
|
|
63
|
+
guard: { tests_run: count("guard_run"), broken_caught: count("guard_broken"), supersedes_accepted: count("supersedes_accepted") },
|
|
64
|
+
decisions: { superseded: count("decision_superseded"), violations: count("decision_violation") },
|
|
65
|
+
low_n: Math.max(lanes.length, guarded.length) < minN,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pure report. guards: parsed FORGE:GUARD objects; reopens: [{issue, ts}]; runs: toModel runs; events: toModel gate events;
|
|
71
|
+
* git: {time(sha)->epoch s|null, oldRanges(sha)->[{file,start,end}]|null, blame(rev,file,start,end)->[sha]|null}.
|
|
72
|
+
*/
|
|
73
|
+
export function compoundingReport({ guards = [], reopens = [], runs = [], events = [], git, windowMs = DEFAULT_WINDOW_MS, buckets = DEFAULT_BUCKETS, now = Date.now(), minN = 10 }) {
|
|
74
|
+
const merged = runs.filter((r) => r.reason === "merged").sort((a, b) => (ms(a.endTs) ?? 0) - (ms(b.endTs) ?? 0) || a.issue - b.issue);
|
|
75
|
+
const byIssue = new Map(merged.map((r, i) => [r.issue, { issue: r.issue, merged: true, rank: i + 1, version: r.version ?? null, at: ms(r.endTs), guarded: false, reopened: false, reintroduced: false }]));
|
|
76
|
+
const gset = new Map();
|
|
77
|
+
for (const g of guards) if (!gset.has(g.issue)) gset.set(g.issue, g);
|
|
78
|
+
const uniq = [...gset.values()];
|
|
79
|
+
for (const g of uniq) {
|
|
80
|
+
const e = byIssue.get(g.issue) ?? { issue: g.issue, merged: false, rank: null, version: null, at: null, guarded: false, reopened: false, reintroduced: false };
|
|
81
|
+
e.guarded = true;
|
|
82
|
+
const secs = git?.time && g.merge_sha ? git.time(g.merge_sha) : null;
|
|
83
|
+
if (e.at == null && secs != null) e.at = secs * 1000;
|
|
84
|
+
byIssue.set(g.issue, e);
|
|
85
|
+
}
|
|
86
|
+
for (const e of byIssue.values()) { // (a) and reopen-after-close: a reopen after the lane closed the issue
|
|
87
|
+
e.reopened = reopens.some((r) => r.issue === e.issue && (e.at == null || (ms(r.ts) ?? 0) > e.at));
|
|
88
|
+
if (e.guarded && e.reopened) e.reintroduced = true;
|
|
89
|
+
}
|
|
90
|
+
const lineage = git && uniq.length ? blameLineage(uniq, git) : { hits: [], unresolved: uniq.length };
|
|
91
|
+
for (const h of lineage.hits) if (byIssue.has(h.fix)) byIssue.get(h.fix).reintroduced = true;
|
|
92
|
+
const entries = [...byIssue.values()];
|
|
93
|
+
const have = Object.fromEntries(COUNTED_EVENTS.map((k) => [k, events.some((e) => e.event === k)]));
|
|
94
|
+
const evMs = (e) => ms(e.ts);
|
|
95
|
+
const out = { rolling: null, by_release: {}, by_lane_age: {}, sources: { guards: uniq.length, reopens: reopens.length, blame_hits: lineage.hits.length, blame_unresolved: lineage.unresolved }, buckets };
|
|
96
|
+
out.rolling = { window_days: Math.round(windowMs / DAY), ...summarize(entries.filter((e) => e.at != null && now - e.at <= windowMs && e.at <= now),
|
|
97
|
+
events.filter((e) => evMs(e) != null && now - evMs(e) <= windowMs && evMs(e) <= now), have, minN) };
|
|
98
|
+
const versions = [...new Set([...entries.map((e) => e.version), ...events.map((e) => e.version)].filter(Boolean))].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
99
|
+
for (const v of versions) out.by_release[v] = summarize(entries.filter((e) => e.version === v), events.filter((e) => e.version === v), have, minN);
|
|
100
|
+
const labels = [...new Set(entries.map((e) => bucketLabel(e.rank, buckets)))];
|
|
101
|
+
const order = (l) => (l === "unranked" ? Infinity : Number.parseInt(l, 10));
|
|
102
|
+
for (const l of labels.sort((a, b) => order(a) - order(b))) {
|
|
103
|
+
const inB = entries.filter((e) => bucketLabel(e.rank, buckets) === l);
|
|
104
|
+
const evs = events.filter((e) => { const r = byIssue.get(e.issue); return r && bucketLabel(r.rank, buckets) === l; });
|
|
105
|
+
out.by_lane_age[l] = summarize(inB, evs, have, minN);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pct = (x) => (x == null ? "n/a" : `${Math.round(x * 1000) / 10}%`);
|
|
111
|
+
const n = (x) => (x == null ? "n/a" : String(x));
|
|
112
|
+
function line(label, s) {
|
|
113
|
+
return ` ${label}: ${s.lanes} lanes, reintroduction ${pct(s.reintroduction.rate)} (${s.reintroduction.reintroduced}/${s.reintroduction.guarded}), reopen-after-close ${pct(s.reopen_after_close.rate)}, `
|
|
114
|
+
+ `guard runs ${n(s.guard.tests_run)} broken ${n(s.guard.broken_caught)} supersedes ${n(s.guard.supersedes_accepted)}, decisions superseded ${n(s.decisions.superseded)} violations ${n(s.decisions.violations)}${s.low_n ? " (low n)" : ""}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function renderCompounding(r) {
|
|
118
|
+
const rows = [`compounding: ${r.sources.guards} guards, ${r.sources.reopens} reopen events, ${r.sources.blame_hits} blame hits (${r.sources.blame_unresolved} guards unresolved)`,
|
|
119
|
+
line(`rolling ${r.rolling.window_days}d`, r.rolling), "by lane age:", ...Object.entries(r.by_lane_age).map(([k, s]) => line(k, s))];
|
|
120
|
+
const rel = Object.entries(r.by_release);
|
|
121
|
+
return [...rows, "by release:", ...(rel.length ? rel.map(([k, s]) => line(k, s)) : [" n/a"])].join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tryGit = (cwd, args) => { try { return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }); } catch { return null; } };
|
|
125
|
+
|
|
126
|
+
/** git probes over a local clone; every miss (unknown sha, shallow clone) is null, never a thrown error. */
|
|
127
|
+
export function gitProbes(cwd) {
|
|
128
|
+
return {
|
|
129
|
+
time: (sha) => { const t = Number(tryGit(cwd, ["show", "-s", "--format=%ct", sha])?.trim()); return Number.isFinite(t) && t > 0 ? t : null; },
|
|
130
|
+
oldRanges: (sha) => {
|
|
131
|
+
const d = tryGit(cwd, ["diff", "-U0", "--no-renames", `${sha}^`, sha]);
|
|
132
|
+
if (d == null) return null;
|
|
133
|
+
const out = []; let file = null;
|
|
134
|
+
for (const l of d.split("\n")) {
|
|
135
|
+
if (l.startsWith("--- ")) { file = l === "--- /dev/null" ? null : l.replace(/^--- a\//, ""); continue; }
|
|
136
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(l);
|
|
137
|
+
if (m && file && m[2] !== "0") out.push({ file, start: Number(m[1]), end: Number(m[1]) + (m[2] === undefined ? 1 : Number(m[2])) - 1 });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
},
|
|
141
|
+
blame: (rev, file, start, end) => {
|
|
142
|
+
const o = tryGit(cwd, ["blame", "--porcelain", "-L", `${start},${end}`, rev, "--", file]);
|
|
143
|
+
return o == null ? null : [...new Set(o.split("\n").filter((l) => /^[0-9a-f]{40} \d+ \d+/.test(l)).map((l) => l.slice(0, 40)))];
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** GitHub + git inputs for compoundingReport: guards from FORGE:GUARD comments, reopen events from one paginated events call. */
|
|
149
|
+
export async function gatherCompounding({ repo, gh, cwd = process.cwd() }) {
|
|
150
|
+
const guards = await collectGuards({ gh, repo });
|
|
151
|
+
let reopens = [];
|
|
152
|
+
try {
|
|
153
|
+
reopens = parsePages(await gh(["api", "--paginate", `repos/${repo}/issues/events?per_page=100`]))
|
|
154
|
+
.filter((e) => e?.event === "reopened" && Number.isSafeInteger(e.issue?.number)).map((e) => ({ issue: e.issue.number, ts: e.created_at }));
|
|
155
|
+
} catch { /* events unreadable: reopen metrics read 0 reopens, shown with the count in sources */ }
|
|
156
|
+
return { guards, reopens, git: gitProbes(cwd) };
|
|
157
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { compoundingReport, renderCompounding, bucketLabel, gitProbes } from "./compounding.mjs";
|
|
6
|
+
import { parseArgs } from "./index.mjs";
|
|
7
|
+
|
|
8
|
+
const NOW = Date.parse("2026-10-24T00:00:00Z"), DAY = 86400000, S = (d) => Math.floor((NOW - d * DAY) / 1000);
|
|
9
|
+
const run = (issue, daysAgo, version = "1.0.0") => ({ issue, reason: "merged", version, endTs: new Date(NOW - daysAgo * DAY).toISOString() });
|
|
10
|
+
const guard = (issue, sha) => ({ v: 1, issue, merge_sha: sha, tests: [{ path: "t.mjs", ids: [] }], hunks: [] });
|
|
11
|
+
// shas: a (issue 1, 40d ago), b (issue 2, 10d ago), c (issue 3, 1d ago)
|
|
12
|
+
const TIMES = { a: S(40), b: S(10), c: S(1) };
|
|
13
|
+
const fakeGit = (blames = {}) => ({ time: (s) => TIMES[s] ?? null, oldRanges: (s) => [{ file: "f.mjs", start: 1, end: 3 }], blame: (rev) => blames[rev] ?? [] });
|
|
14
|
+
const base = { runs: [run(1, 40), run(2, 10), run(3, 1)], guards: [guard(1, "a"), guard(2, "b"), guard(3, "c")], now: NOW, minN: 1 };
|
|
15
|
+
|
|
16
|
+
test("reintroduction (b): a later fix whose old lines blame to an earlier fix within 90 days", () => {
|
|
17
|
+
const r = compoundingReport({ ...base, git: fakeGit({ "b^": ["a"] }) });
|
|
18
|
+
assert.deepEqual(r.rolling.reintroduction, { guarded: 2, reintroduced: 0, rate: 0 }); // 30d window holds fixes 2, 3; the broken fix is #1 (40d ago)
|
|
19
|
+
assert.equal(r.sources.blame_hits, 1);
|
|
20
|
+
const all = Object.values(r.by_lane_age)[0];
|
|
21
|
+
assert.deepEqual(all.reintroduction, { guarded: 3, reintroduced: 1, rate: 0.333 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("reintroduction (b): older than 90 days or unknown time does not count; unresolved is reported, not zeroed", () => {
|
|
25
|
+
const far = { ...base, guards: [guard(1, "a"), guard(2, "b")], runs: [run(1, 200), run(2, 10)] };
|
|
26
|
+
const r = compoundingReport({ ...far, git: { ...fakeGit({ "b^": ["a"] }), time: (s) => ({ a: S(200), b: S(10) })[s] ?? null } });
|
|
27
|
+
assert.equal(r.sources.blame_hits, 0);
|
|
28
|
+
const u = compoundingReport({ ...base, git: { time: () => null, oldRanges: () => null, blame: () => null } });
|
|
29
|
+
assert.equal(u.sources.blame_unresolved, 3);
|
|
30
|
+
assert.equal(u.sources.blame_hits, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("reintroduction (a) and reopen-after-close: reopen after the lane closed the issue", () => {
|
|
34
|
+
const reopens = [{ issue: 2, ts: new Date(NOW - 5 * DAY).toISOString() }, { issue: 3, ts: new Date(NOW - 3 * DAY).toISOString() /* before close? no: close was 1d ago */ }];
|
|
35
|
+
const r = compoundingReport({ ...base, reopens, git: fakeGit() });
|
|
36
|
+
assert.equal(r.rolling.reopen_after_close.reopened, 1); // issue 2 reopened after its close; issue 3's reopen predates its close
|
|
37
|
+
assert.equal(r.rolling.reintroduction.reintroduced, 1);
|
|
38
|
+
assert.equal(r.rolling.lanes, 2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("guard and decision counts are n/a until a kind is recorded, then counted per window", () => {
|
|
42
|
+
const none = compoundingReport({ ...base, git: fakeGit() });
|
|
43
|
+
assert.equal(none.rolling.guard.broken_caught, null);
|
|
44
|
+
assert.equal(none.rolling.decisions.violations, null);
|
|
45
|
+
const events = [{ event: "guard_broken", issue: 2, ts: new Date(NOW - DAY).toISOString(), version: "1.0.0" }, { event: "guard_broken", issue: 1, ts: new Date(NOW - 60 * DAY).toISOString(), version: "1.0.0" }];
|
|
46
|
+
const r = compoundingReport({ ...base, events, git: fakeGit() });
|
|
47
|
+
assert.equal(r.rolling.guard.broken_caught, 1);
|
|
48
|
+
assert.equal(r.rolling.guard.tests_run, null);
|
|
49
|
+
assert.equal(r.by_release["1.0.0"].guard.broken_caught, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("lane-age buckets by merge order and rendering", () => {
|
|
53
|
+
assert.deepEqual([1, 100, 101, 500, 501, null].map((n) => bucketLabel(n, [100, 500])), ["1-100", "1-100", "101-500", "101-500", "501+", "unranked"]);
|
|
54
|
+
const runs = Array.from({ length: 4 }, (_, i) => run(i + 1, 40 - i));
|
|
55
|
+
const r = compoundingReport({ runs, guards: [], buckets: [2], now: NOW, minN: 1, git: fakeGit() });
|
|
56
|
+
assert.deepEqual(Object.keys(r.by_lane_age), ["1-2", "3+"]);
|
|
57
|
+
assert.equal(r.by_lane_age["3+"].lanes, 2);
|
|
58
|
+
assert.match(renderCompounding(r), /by lane age:\n 1-2: 2 lanes, reintroduction n\/a \(0\/0\)/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("gitProbes degrades to null outside a repo or for an unknown sha", () => {
|
|
62
|
+
const g = gitProbes("/nonexistent-dir-for-compounding");
|
|
63
|
+
assert.equal(g.time("deadbeef"), null);
|
|
64
|
+
assert.equal(g.oldRanges("deadbeef"), null);
|
|
65
|
+
assert.equal(g.blame("deadbeef^", "x", 1, 2), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parseArgs: --compounding takes --window and --bucket; --bucket alone is rejected", () => {
|
|
69
|
+
const o = parseArgs(["--compounding", "--window", "30d", "--bucket", "50,200"]);
|
|
70
|
+
assert.equal(o.compounding, true);
|
|
71
|
+
assert.deepEqual(o.bucket, [50, 200]);
|
|
72
|
+
assert.throws(() => parseArgs(["--bucket", "10"]), /needs --compounding/);
|
|
73
|
+
assert.throws(() => parseArgs(["--compounding", "--bucket", "9,5"]), /increasing/);
|
|
74
|
+
});
|
package/bin/stats/index.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { batchEfficiency, windowEfficiency, compareEfficiency, formatEfficiencyL
|
|
|
19
19
|
import { releaseMetrics, renderReleaseMetrics, metricsComment, postMetrics } from "./release.mjs";
|
|
20
20
|
import { experimentReport, renderExperiment } from "./experiment.mjs";
|
|
21
21
|
import { readExperiment } from "../experiment.mjs";
|
|
22
|
+
import { compoundingReport, gatherCompounding, renderCompounding, DEFAULT_BUCKETS, DEFAULT_WINDOW_MS } from "./compounding.mjs";
|
|
22
23
|
import { poolUtilisation, formatPoolLines } from "./pool.mjs";
|
|
23
24
|
import { batchRoot } from "../lean/batch.mjs";
|
|
24
25
|
|
|
@@ -29,6 +30,7 @@ export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <ow
|
|
|
29
30
|
forgedock stats --experiment <id> [--require-met] [--min-n N] [--repo <owner/repo>] [--json]
|
|
30
31
|
forgedock stats --by version [--post-metrics <pr>] [--min-n N] [--repo <owner/repo>] [--json]
|
|
31
32
|
forgedock stats --pool [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
33
|
+
forgedock stats --compounding [--window 30d] [--bucket 100,500] [--since <date>] [--min-n N] [--repo <owner/repo>] [--json]
|
|
32
34
|
forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--compare <date|version>] [--offline] [--repo <owner/repo>] [--json]
|
|
33
35
|
|
|
34
36
|
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
@@ -84,6 +86,13 @@ Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues
|
|
|
84
86
|
their delta and full-lane cohorts matched per size (forge#4491)
|
|
85
87
|
--offline skip the GitHub crawl for merged issues (issue quality and acceptance print n/a)
|
|
86
88
|
|
|
89
|
+
Compounding (forge#4696; GitHub + git of the current clone, no model):
|
|
90
|
+
--compounding reintroduction rate (guarded fix reopened, or its lines blamed by a later guarded fix within 90 days),
|
|
91
|
+
reopen-after-close rate, guard activity (guard_run, guard_broken, supersedes_accepted) and decision
|
|
92
|
+
churn (decision_superseded, decision_violation), as a rolling window, per release and per lane-age
|
|
93
|
+
bucket; n/a where no data is recorded. Pair with --efficiency --compare for $/merge and turns/merge
|
|
94
|
+
--bucket <list> lane-age bucket upper bounds (default 100,500: lanes 1-100, 101-500, 501+)
|
|
95
|
+
|
|
87
96
|
Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
|
|
88
97
|
--tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
|
|
89
98
|
(mid, mid->top escalated, top) and routing class; --json for JSON
|
|
@@ -98,7 +107,7 @@ Lane profile baseline (forge#4436; local run logs only, every run of each issue,
|
|
|
98
107
|
export function experimentExit(opts, report) { return opts.requireMet && report.targets_met !== true ? 1 : 0; }
|
|
99
108
|
|
|
100
109
|
export function parseArgs(argv) {
|
|
101
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
110
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, compounding: false, bucket: null, laneProfile: false, issues: null, efficiency: false, pool: false, batch: null, windows: null, compare: null, by: null, experiment: null, emitEvent: false, requireMet: false, postMetrics: null, offline: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
102
111
|
const val = (i, name) => {
|
|
103
112
|
const v = argv[i + 1];
|
|
104
113
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -117,6 +126,8 @@ export function parseArgs(argv) {
|
|
|
117
126
|
else if (name === "--context-ab") o.contextAb = true;
|
|
118
127
|
else if (name === "--explore-ab") o.exploreAb = true;
|
|
119
128
|
else if (name === "--tiers") o.tiers = true;
|
|
129
|
+
else if (name === "--compounding") o.compounding = true;
|
|
130
|
+
else if (name === "--bucket") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--bucket must be a comma list of lane counts"); const b = v.split(",").map(Number); if (b.some((x, i) => i && x <= b[i - 1])) throw new Error("--bucket must be increasing"); o.bucket = b; }
|
|
120
131
|
else if (name === "--lane-profile") o.laneProfile = true;
|
|
121
132
|
else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
|
|
122
133
|
else if (name === "--efficiency") o.efficiency = true;
|
|
@@ -144,7 +155,8 @@ export function parseArgs(argv) {
|
|
|
144
155
|
else throw new Error(`unknown argument: ${a}`);
|
|
145
156
|
}
|
|
146
157
|
if (o.batch && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
147
|
-
if (o.windows && !o.efficiency && !o.pool) throw new Error("--batch and --window need --efficiency");
|
|
158
|
+
if (o.windows && !o.efficiency && !o.pool && !o.compounding) throw new Error("--batch and --window need --efficiency");
|
|
159
|
+
if (o.bucket && !o.compounding) throw new Error("--bucket needs --compounding");
|
|
148
160
|
if ((o.compare || o.offline) && !o.efficiency) throw new Error("--compare and --offline need --efficiency");
|
|
149
161
|
if (o.postMetrics && !o.by) throw new Error("--post-metrics needs --by version");
|
|
150
162
|
return o;
|
|
@@ -302,6 +314,12 @@ export async function buildReport(opts, deps = {}) {
|
|
|
302
314
|
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
|
|
303
315
|
}
|
|
304
316
|
const model = toModel(repo, gh);
|
|
317
|
+
if (opts.compounding) { // forge#4696: GitHub records + guards + git, no model
|
|
318
|
+
const inputs = await (deps.gatherCompounding ?? gatherCompounding)({ repo, gh: deps.gh ?? defaultGh });
|
|
319
|
+
const query = { compounding: compoundingReport({ ...inputs, runs: model.runs, events: model.events, windowMs: opts.windows?.[0] ?? DEFAULT_WINDOW_MS,
|
|
320
|
+
buckets: opts.bucket ?? DEFAULT_BUCKETS, now: deps.now?.() ?? Date.now(), minN: opts.minN }) };
|
|
321
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache } }, query };
|
|
322
|
+
}
|
|
305
323
|
const ghKeys = new Set(model.runs.map((r) => `${r.repo}#${r.issue}`));
|
|
306
324
|
let local = [];
|
|
307
325
|
if (opts.local) {
|
|
@@ -348,6 +366,7 @@ export async function runStats(argv, deps = {}) {
|
|
|
348
366
|
if (report.query?.experiment) { process.stdout.write(`${opts.json ? renderJson(report) : renderExperiment(report.query.experiment)}\n`); return experimentExit(opts, report.query.experiment); }
|
|
349
367
|
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
350
368
|
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
369
|
+
if (report.query?.compounding && !opts.json) { process.stdout.write(`${renderCompounding(report.query.compounding)}\n`); return 0; }
|
|
351
370
|
if (report.query?.pool && !opts.json) { process.stdout.write(`${formatPoolLines(report.query.pool).join("\n")}\n`); return 0; }
|
|
352
371
|
if (report.query?.efficiency && !opts.json) {
|
|
353
372
|
const e = report.query.efficiency;
|
package/commands/bootstrap.md
CHANGED
|
@@ -18,18 +18,20 @@ BOOT_TPL="$(forge_root)/templates/bootstrap"; VALIDATE=$(forge_resolve bootstrap
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
1. **Read the input.** Use the free text, or read each doc at the given path(s).
|
|
21
|
-
2. **Interview** from `interview.md`. Cover
|
|
22
|
-
3. **Fill the blanks.**
|
|
23
|
-
4. **Ask only genuine doubts** (
|
|
21
|
+
2. **Interview** from `interview.md`. Cover each aspect briefly; skip what the input answers.
|
|
22
|
+
3. **Fill the blanks.** Default every unanswered aspect with a one-line rationale, using the closest `stack-profiles.md` preset. Show them in ONE editable "Here's what I assumed" list and apply the user's edits.
|
|
23
|
+
4. **Ask only genuine doubts** (architecture or cost: native vs PWA, data residency, self-host vs managed DB, product name, repo owner). Ask with AskUserQuestion, at most 4 multiple-choice questions per batch, each with a recommended option. Everything else stays a default. Headless (no AskUserQuestion, e.g. `npx forgedock bootstrap`): take each recommended option and list it under "Here's what I assumed".
|
|
24
24
|
5. **Spike doc.** Write `technical-spike.md` to `--spike` (default `docs/engineering/technical-spike.md`); set the plan's `spikeDoc` to that path. Fill every section and give each decision an ADR-style `**Rationale**:` line.
|
|
25
25
|
6. **Plan JSON** in `$TMPDIR` (shape: header of `bootstrap-validate.mjs` and `bootstrap-create.mjs`):
|
|
26
|
-
- Milestones
|
|
26
|
+
- Milestones as dependency layers, foundation first: each depends only on earlier ones (#4659).
|
|
27
|
+
- A thin deployable slice (CI deploy workflow, staging env, seed) in milestone 1 or 2, never last.
|
|
27
28
|
- A `SPIKE` parent issue.
|
|
28
29
|
- Sub-issues from `issue-body.md` with planned paths, `[type:*]` tags, `priority` and `component`.
|
|
29
30
|
- `"ui": true` issues carry the full design spec. `"hero": true` issues route to `/design`.
|
|
30
|
-
- One `needs-human-issue.md` issue per credential, account or external input. Code issues reference it as `#{{KEY}}
|
|
31
|
+
- One `needs-human-issue.md` issue per credential, account or external input, with `gates:` (#4660). Code issues reference it as `#{{KEY}}`, never depend on it, and name the mock that keeps dev and CI green.
|
|
31
32
|
- Sub-issues point at the spike via `**Parent**` only, never `Depends on`.
|
|
32
33
|
- `guardrails` and `domainRules` from the interview.
|
|
33
|
-
7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Never create from an invalid plan.
|
|
34
|
-
8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched).
|
|
35
|
-
9. **Hand off.** Print the script's hand-off verbatim:
|
|
34
|
+
7. **Validate.** Run `node "$VALIDATE" <plan>`. Fix the plan until it exits 0. Heed `deploy-last` warnings. Never create from an invalid plan.
|
|
35
|
+
8. **Create.** Run `node "$CREATE" <plan>`, adding `--dry-run` when the input has it (preview only, GitHub untouched). It reuses existing issues, so on an error re-run it to resume.
|
|
36
|
+
9. **Hand off.** Print the script's hand-off verbatim: created (links), needs a human (links), and the `/orchestrate` command per milestone, in order.
|
|
37
|
+
10. Later: `npx forgedock plan validate --live` (#4659).
|
package/commands/deploy-info.md
CHANGED
|
@@ -325,6 +325,7 @@ else
|
|
|
325
325
|
# Step 3: Count commits queued (commits in staging not yet in main)
|
|
326
326
|
QUEUED_COMMITS=$(git rev-list --count origin/{DEFAULT_BRANCH}..origin/{STAGING_BRANCH} 2>/dev/null || echo "?")
|
|
327
327
|
fi
|
|
328
|
+
INTEGRATION_RED=$(gh issue list {GH_FLAG} --label integration-red --json number,title --jq '.[]|"#\(.number) \(.title)"') # forge#4667
|
|
328
329
|
```
|
|
329
330
|
|
|
330
331
|
Train state is included in the Phase 5 output summary.
|
|
@@ -339,10 +340,12 @@ Train state is included in the Phase 5 output summary.
|
|
|
339
340
|
**Commits**: {N} | **PRs**: {N} | **Issues closed**: {N} | **Files changed**: {N}
|
|
340
341
|
|
|
341
342
|
### Deploy Train State
|
|
343
|
+
**Gate**: `node ${FORGEDOCK_HOME:-${CLAUDE_PLUGIN_ROOT}}/scripts/readiness.mjs --target {preview|staging|prod}`; NOT DEPLOYABLE (open prerequisites) means never call the target deployable (#4660).
|
|
342
344
|
**Train PR**: #{TRAIN_PR_NUMBER} (SHA: {TRAIN_PR_SHA}, opened: {TRAIN_PR_CREATED})
|
|
343
345
|
**Status**: {TRAIN_STATUS}
|
|
344
346
|
{If HELD: "**Held by**: {count} open finding(s) — fix and push to unblock:\n{HELD_BY}"}
|
|
345
347
|
{If CLEAR: "**Ready to merge**: no open findings on the train PR"}
|
|
348
|
+
{If INTEGRATION_RED: "**Ship-gate risk**: red integration run:\n{INTEGRATION_RED}"}
|
|
346
349
|
{If NO_TRAIN: "No open staging→main PR — run /review-pr-staging to open one"}
|
|
347
350
|
**Queued commits**: {QUEUED_COMMITS} (commits in staging not yet in main)
|
|
348
351
|
|
package/commands/issue.md
CHANGED
|
@@ -85,6 +85,8 @@ Duplicates: the script's token-overlap dedup is authoritative. Before drafting,
|
|
|
85
85
|
|
|
86
86
|
### 3. Draft
|
|
87
87
|
|
|
88
|
+
**External prerequisite** (credential, account, sign-off): label `needs-human:external` and put `gates: preview|staging-deploy|prod-deploy|feature-flag:<name>` and `owner:` in the body (#4660).
|
|
89
|
+
|
|
88
90
|
**Title**: `{prefix}({scope}): {specific description}`, max 80 characters, no vague verbs ("improve", "update", "handle") without specifics.
|
|
89
91
|
|
|
90
92
|
**Body** (pipeline template; `## Problem`, `## Affected Files` and `## Acceptance Criteria` are mandatory, the rest optional; in greenfield mode Affected Files may list *planned* paths inside the spike's declared repo layout):
|
package/commands/milestone.md
CHANGED
|
@@ -96,6 +96,8 @@ Parse `$ARGUMENTS` to determine which action to take:
|
|
|
96
96
|
| `drift <slug>` | → **Milestone Drift** |
|
|
97
97
|
| `close <slug>` | → **Close Milestone** |
|
|
98
98
|
|
|
99
|
+
Unattended multi-milestone runs use the CLI, not this spec: `forgedock milestone train <slug...> [--parallel-milestones N]` (resume with `--resume <id>`, add a milestone with `--insert <id> <slug>`). It keeps one state file, reuses the lifecycle above and never edits `forge.yaml`. See `devdocs/decisions/4658-milestone-train.md`.
|
|
100
|
+
|
|
99
101
|
---
|
|
100
102
|
|
|
101
103
|
## Action: Create Milestone
|
|
@@ -356,6 +358,10 @@ else
|
|
|
356
358
|
fi
|
|
357
359
|
```
|
|
358
360
|
|
|
361
|
+
### Step 6C: Re-validate dependency order
|
|
362
|
+
|
|
363
|
+
After a milestone is added, the train order changes or an issue is decomposed, run `npx forgedock plan validate --live` (#4659). Fix every `invalid` edge (a dependency in a later milestone) before continuing; `held` edges are reported only.
|
|
364
|
+
|
|
359
365
|
### Step 7: Report
|
|
360
366
|
|
|
361
367
|
```
|
|
@@ -444,37 +450,28 @@ git fetch origin milestone/{slug}
|
|
|
444
450
|
OPEN_COUNT=$(gh issue list --milestone "{TITLE}" --state open --json number --jq '. | length')
|
|
445
451
|
```
|
|
446
452
|
|
|
447
|
-
If there are open issues,
|
|
453
|
+
If there are open issues, this is a hard gate (interactive and unattended; unattended runs fail instead of asking). Every open issue must be **carried over** with a target milestone *and* a class (`engineering|external|post-deploy|wont-do`), or closed first. The class comes from labels (`carry:<class>`, `needs-human:external`, `awaiting-promotion|awaiting-precondition|awaiting-measurement`, `wont-do`; else `engineering`); the target is a `carry-to:<milestone title>` label or the user's answer. Run the check with `preflightCarryOver` (`bin/lean/lifecycle.mjs`):
|
|
454
|
+
|
|
448
455
|
```
|
|
449
|
-
⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues:
|
|
450
|
-
{list them}
|
|
456
|
+
⚠ Milestone "{TITLE}" still has {OPEN_COUNT} open issues without a carry-over target:
|
|
457
|
+
{list them with their class}
|
|
451
458
|
|
|
452
|
-
Ship
|
|
459
|
+
Name a target milestone for each (or close it). Ship is blocked until every open issue has one.
|
|
453
460
|
```
|
|
454
461
|
|
|
455
|
-
|
|
462
|
+
An issue that is `needs-human:external`, flag/legal-gated (`flag-gated`, `legal-gated`) or depends on an issue that is not on the target base is **never** demoted to fast lane: it is carried to its target milestone.
|
|
456
463
|
|
|
457
464
|
### Step 2: Sync milestone branch with staging
|
|
458
465
|
|
|
459
|
-
Before shipping,
|
|
466
|
+
Before shipping, bring the milestone branch up to date with `staging` (picks up fast-lane fixes that accumulated since it was cut). `forgedock milestone-sync` does it deterministically (#4663): a milestone already `shipped:*`/`deployed:default`/`closed` is refused, a branch already containing staging is a no-op, append-only registry conflicts are resolved by the lane base-merge resolver (`lean.base_merge_*`), and a protected branch (ruleset or branch protection, or a push the rules reject) is synced through the single `sync/milestone-{slug}` PR with auto-merge armed (merged directly when CLEAN). A `ship` sync is never debounced.
|
|
460
467
|
|
|
461
468
|
```bash
|
|
462
469
|
cd {REPO_PATH}
|
|
463
|
-
|
|
464
|
-
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
465
|
-
SYNC_WT=$(mktemp -d)
|
|
466
|
-
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
467
|
-
if git -C "$SYNC_WT" merge origin/{STAGING_BRANCH} --no-edit; then
|
|
468
|
-
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
469
|
-
git worktree remove --force "$SYNC_WT"
|
|
470
|
-
else
|
|
471
|
-
# Conflicts: report to user and STOP — do not auto-resolve
|
|
472
|
-
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
473
|
-
git worktree remove --force "$SYNC_WT"
|
|
474
|
-
echo "STOP: merge conflict syncing milestone/{slug} with {STAGING_BRANCH}"; exit 1
|
|
475
|
-
fi
|
|
470
|
+
forgedock milestone-sync {slug} --reason ship --base {STAGING_BRANCH} --repo {owner}/{repo} || { echo "STOP: milestone sync failed or conflicts"; exit 1; }
|
|
476
471
|
```
|
|
477
472
|
|
|
473
|
+
If the sync went through a PR (`pr-created`/`pr-updated`), wait for it to merge before Step 2.5.
|
|
474
|
+
|
|
478
475
|
### Step 2.5: Pre-Merge Hunk-Loss Audit (MANDATORY before creating PR)
|
|
479
476
|
|
|
480
477
|
**WHY THIS EXISTS**: When a long-lived milestone branch is merged into staging, git hunks that exist in staging but were NOT touched by the milestone can be silently dropped. This happened in a 20h window that produced 13 regression-fix PRs (27% of total pipeline throughput) restoring lost code: postgres tuning params, completion_refund sweep, credit_ledger columns, browser flags, pool guards, Redis config, crawl TTL refresh, challenge_strategy guard. Each regression required investigation → new issue → new PR → review → merge — consuming nearly a third of pipeline capacity on damage repair instead of forward progress.
|
|
@@ -730,24 +727,28 @@ Run /milestone ship {slug} again when the milestone is ready for another shippin
|
|
|
730
727
|
```bash
|
|
731
728
|
gh pr merge {PR_NUMBER} --merge
|
|
732
729
|
|
|
733
|
-
# Step 6A:
|
|
734
|
-
#
|
|
735
|
-
|
|
736
|
-
|
|
730
|
+
# Step 6A: Carry over remaining open issues (forge#4657). Never clear the milestone: every open issue moves to its
|
|
731
|
+
# target milestone (Step 1 gate) and keeps its class label. Only an issue whose preflightCarryOver entry has demote=true
|
|
732
|
+
# and whose target is "fast lane" may lose its milestone; external, gated and off-base-dependent issues are never demoted.
|
|
733
|
+
OPEN_ISSUES=$(gh api "repos/{owner}/{repo}/issues?milestone={MS_NUMBER}&state=open&per_page=100" --jq '.[].number')
|
|
737
734
|
OPEN_COUNT=$(echo "$OPEN_ISSUES" | grep -c '[0-9]' 2>/dev/null || echo 0)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
echo "
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
735
|
+
for ISSUE_NUM in $OPEN_ISSUES; do
|
|
736
|
+
gh issue edit "$ISSUE_NUM" --milestone "{TARGET_TITLE_OF_ISSUE}" --add-label "carry:{CLASS_OF_ISSUE}" # <!-- allowlist:check-command-side-effects -->
|
|
737
|
+
echo " Carried #$ISSUE_NUM -> {TARGET_TITLE_OF_ISSUE} ({CLASS_OF_ISSUE})"
|
|
738
|
+
done
|
|
739
|
+
|
|
740
|
+
# Step 6B: Record the lifecycle. The merge is on staging, not yet on the default branch: write
|
|
741
|
+
# a FORGE:LIFECYCLE record ({"v":1,"state":"shipped:staging","sha":"<merge commit>"}) in the milestone description and
|
|
742
|
+
# close the milestone only when forge.yaml `milestones.close_on` (default `default`) is reached. With close_on=default the
|
|
743
|
+
# milestone stays open until the staging -> default merge is observed (deployed:default), then it closes.
|
|
744
|
+
MERGE_SHA=$(gh pr view {PR_NUMBER} --json mergeCommit --jq '.mergeCommit.oid')
|
|
745
|
+
DESC=$(gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --jq '.description // ""')
|
|
746
|
+
TAG="FORGE:LIFECYCLE"
|
|
747
|
+
BLOCK="<!-- $TAG {\"v\":1,\"state\":\"shipped:staging\",\"sha\":\"$MERGE_SHA\"} -->"
|
|
748
|
+
gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field description="$(printf '%s\n\n%s' "$(printf '%s' "$DESC" | sed "/$TAG/d")" "$BLOCK")"
|
|
749
|
+
# close_on=staging only (and only when no issue is left open on the milestone):
|
|
750
|
+
# gh api repos/{owner}/{repo}/milestones/{MILESTONE_NUMBER} --method PATCH --field state="closed"
|
|
751
|
+
# Orchestrate-driven runners get the same transition from bin/lean/lifecycle.mjs advanceMilestone at batch end.
|
|
751
752
|
|
|
752
753
|
# Step 6C: Delete the milestone branch
|
|
753
754
|
git push origin --delete milestone/{slug}
|
|
@@ -758,8 +759,8 @@ Report:
|
|
|
758
759
|
```
|
|
759
760
|
## Shipped: {MILESTONE_TITLE}
|
|
760
761
|
|
|
761
|
-
Merged to staging. Milestone branch deleted.
|
|
762
|
-
{If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s)
|
|
762
|
+
Merged to staging (lifecycle: shipped:staging). Milestone branch deleted. Milestone closes at `milestones.close_on` ({close_on}).
|
|
763
|
+
{If OPEN_COUNT > 0: "{OPEN_COUNT} open issue(s) carried over: #{list} -> {target} ({class}). None were demoted to fast lane."}
|
|
763
764
|
{If OPEN_COUNT == 0: "No open issues remaining — clean close."}
|
|
764
765
|
|
|
765
766
|
To deploy: merge `staging → main` via GitHub web UI when ready.
|
|
@@ -769,27 +770,15 @@ To deploy: merge `staging → main` via GitHub web UI when ready.
|
|
|
769
770
|
|
|
770
771
|
## Action: Sync Milestone Branch
|
|
771
772
|
|
|
772
|
-
Syncs the milestone branch with the latest default branch to pick up fast-lane fixes.
|
|
773
|
+
Syncs the milestone branch with the latest default branch to pick up fast-lane fixes. Sync **on demand only**: before ship, before a batch, or when a conflict is expected. Never on every base move, and never a milestone whose lifecycle is `shipped:*` (the command refuses it; a shipped milestone is also never the "active" one to sync, `isSyncable` in `bin/lean/milestonesync.mjs`).
|
|
773
774
|
|
|
774
775
|
```bash
|
|
775
776
|
cd {REPO_PATH}
|
|
776
777
|
DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
|
|
777
|
-
|
|
778
|
-
# Never mutate the main checkout: sync in a detached worktree at the milestone tip
|
|
779
|
-
SYNC_WT=$(mktemp -d)
|
|
780
|
-
git worktree add --detach "$SYNC_WT" origin/milestone/{slug}
|
|
781
|
-
if git -C "$SYNC_WT" merge origin/$DEFAULT_BRANCH --no-edit; then
|
|
782
|
-
git -C "$SYNC_WT" push origin HEAD:refs/heads/milestone/{slug}
|
|
783
|
-
git worktree remove --force "$SYNC_WT"
|
|
784
|
-
else
|
|
785
|
-
# If conflicts, report to user and STOP
|
|
786
|
-
git -C "$SYNC_WT" merge --abort 2>/dev/null || true
|
|
787
|
-
git worktree remove --force "$SYNC_WT"
|
|
788
|
-
echo "STOP: merge conflict syncing milestone/{slug} with $DEFAULT_BRANCH"; exit 1
|
|
789
|
-
fi
|
|
778
|
+
forgedock milestone-sync {slug} --reason manual --base "$DEFAULT_BRANCH" --repo {owner}/{repo}
|
|
790
779
|
```
|
|
791
780
|
|
|
792
|
-
|
|
781
|
+
Protected branches get ONE sync PR (`sync/milestone-{slug}`) whose head is force-pushed in place; a base that moved again inside `milestones.sync_debounce` (default 10m) reports `debounced` and leaves the PR (and its CI) alone. Report the printed status.
|
|
793
782
|
|
|
794
783
|
---
|
|
795
784
|
|
|
@@ -828,5 +817,5 @@ This automatically makes the issue a feature-lane issue — next time `/work-on`
|
|
|
828
817
|
|
|
829
818
|
- **Milestone already exists**: Check first with `gh api repos/{owner}/{repo}/milestones --jq '.[] | select(.title == "{TITLE}")'`
|
|
830
819
|
- **Branch already exists**: Reuse it, don't recreate
|
|
831
|
-
- **Merge conflicts on sync/ship**:
|
|
820
|
+
- **Merge conflicts on sync/ship**: append-only registry conflicts matching `lean.base_merge_*` are resolved deterministically by `milestone-sync`; any other conflict is reported with its files and the branch is left untouched. Do NOT hand-resolve.
|
|
832
821
|
- **No issues in milestone**: Warn user — empty milestones are valid but unusual
|
|
@@ -1581,6 +1581,8 @@ The merged-trail hold on #${N} was released by a human (needs-human cleared or t
|
|
|
1581
1581
|
return 0
|
|
1582
1582
|
}
|
|
1583
1583
|
|
|
1584
|
+
# gh failures are unknown, not state (forge#4662): in every hold/settle/ship read, a failed gh call (HTTP 401, 5xx, timeout) is never
|
|
1585
|
+
# "not closed" or "no PR". Keep the previous classification, back off, and after 5 consecutive failures post one alert and stop that loop.
|
|
1584
1586
|
# classify_predecessor_state <issue> (forge#3168): the helpers hold_merged_trail, resolve_orch_login,
|
|
1585
1587
|
# trail_escalation_state and release_merged_trail above MUST be declared in the SAME shell before this function runs
|
|
1586
1588
|
# (reverify_merged_trail is nested below). It has exactly ONE guarded write (the DRY_RUN-guarded hold_merged_trail
|
package/commands/orchestrate.md
CHANGED
|
@@ -105,7 +105,7 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
|
|
|
105
105
|
for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
|
|
106
106
|
(`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
|
|
107
107
|
`forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
|
|
108
|
-
return to pending with it; a finished batch is reopened); `--add`
|
|
108
|
+
return to pending with it; a finished batch is reopened); `--add` on a `skipped` issue re-queues it (other ended issues still report `already in batch`). A dependency-skipped issue shows as `waiting on #X` while the batch runs (the blocker is re-checked each pass; merged or closed re-admits it, closed as not planned keeps it skipped) and ends `skipped` only if the blocker never resolved. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
|
|
109
109
|
retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
|
|
110
110
|
the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
|
|
111
111
|
deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
|
|
@@ -193,6 +193,13 @@ Escalate only these:
|
|
|
193
193
|
Collect escalations into one question list and ask it once, at the end of the batch. Never ask per issue, and keep every
|
|
194
194
|
other lane running meanwhile.
|
|
195
195
|
|
|
196
|
+
## 3b. Settle scope (#4665)
|
|
197
|
+
|
|
198
|
+
When settle begins, freeze the milestone's issue set (`scripts/settle-scope.mjs` `freezeScope`). Issues filed later are
|
|
199
|
+
classified by `classifyNew`: decomposition children of a frozen issue stay in the milestone and join the measured set;
|
|
200
|
+
deferred follow-ups go to the next milestone or the backlog; review findings and others count as growth only.
|
|
201
|
+
`convergence` measures the frozen set plus those children; a STOP prints `formatStopReport` (growth and sources).
|
|
202
|
+
|
|
196
203
|
## 4. Report
|
|
197
204
|
|
|
198
205
|
When all lanes have finished, print one table: issue, outcome, PR, minutes (issues the pre-filter closed
|