forgedock 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge#4657: milestone lifecycle `building -> shipped:staging -> deployed:default -> closed`, recorded as one
|
|
3
|
+
* `<!-- FORGE:LIFECYCLE {json} -->` block in the milestone description (same register as FORGE:DRIFT, drift.mjs).
|
|
4
|
+
* Pure helpers plus `advanceMilestone`, which takes an injected `gh`/`git`. No LLM.
|
|
5
|
+
*/
|
|
6
|
+
import { readMilestone } from "./drift.mjs";
|
|
7
|
+
|
|
8
|
+
export const LIFECYCLE_MARKER = "FORGE:LIFECYCLE";
|
|
9
|
+
export const LIFECYCLE_STATES = ["building", "shipped:staging", "deployed:default", "closed"];
|
|
10
|
+
export const CARRY_CLASSES = ["engineering", "external", "post-deploy", "wont-do"];
|
|
11
|
+
const BLOCK_RE = new RegExp(`<!--\\s*${LIFECYCLE_MARKER}\\s+(\\{[\\s\\S]*?\\})\\s*-->`);
|
|
12
|
+
const POST_DEPLOY_LABELS = ["awaiting-promotion", "awaiting-due", "awaiting-precondition", "awaiting-measurement"];
|
|
13
|
+
const GATED_LABELS = ["flag-gated", "legal-gated", "legal-hold"];
|
|
14
|
+
const arr = (x) => (Array.isArray(x) ? x : []);
|
|
15
|
+
const names = (labels) => arr(labels).map((l) => String(l?.name ?? l));
|
|
16
|
+
|
|
17
|
+
/** The FORGE:LIFECYCLE record in a milestone description ({v, state, sha?}), or null. */
|
|
18
|
+
export function parseLifecycle(description) {
|
|
19
|
+
const m = BLOCK_RE.exec(String(description ?? ""));
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) && LIFECYCLE_STATES.includes(s.state) ? s : null; } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `description` with its FORGE:LIFECYCLE block replaced (or appended); the text around it is unchanged. */
|
|
25
|
+
export function renderLifecycle(description, record) {
|
|
26
|
+
const d = String(description ?? "");
|
|
27
|
+
const block = `<!-- ${LIFECYCLE_MARKER} ${JSON.stringify({ v: 1, ...record }).replace(/>/g, "\\u003e")} -->`;
|
|
28
|
+
if (BLOCK_RE.test(d)) return d.replace(BLOCK_RE, () => block);
|
|
29
|
+
return d ? `${d}\n\n${block}` : block;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `state` moves forward only: the later of `from` and `to` (a missing `from` is `building`). */
|
|
33
|
+
export function advanceState(from, to) {
|
|
34
|
+
const a = Math.max(0, LIFECYCLE_STATES.indexOf(from)), b = LIFECYCLE_STATES.indexOf(to);
|
|
35
|
+
return LIFECYCLE_STATES[Math.max(a, b)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** forge.yaml `milestones.close_on`: `staging` or `default` (the default). */
|
|
39
|
+
export const parseCloseOn = (v) => (String(v ?? "").trim().toLowerCase() === "staging" ? "staging" : "default");
|
|
40
|
+
|
|
41
|
+
/** True when `state` has reached the point `closeOn` closes the milestone at (and it is not closed yet). */
|
|
42
|
+
export function closeDue(state, closeOn = "default") {
|
|
43
|
+
if (state === "closed") return false;
|
|
44
|
+
return LIFECYCLE_STATES.indexOf(state) >= LIFECYCLE_STATES.indexOf(parseCloseOn(closeOn) === "staging" ? "shipped:staging" : "deployed:default");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Carry-over class of an open issue from its labels: external, post-deploy, wont-do, else engineering. */
|
|
48
|
+
export function classifyCarryOver(issue) {
|
|
49
|
+
const l = names(issue?.labels);
|
|
50
|
+
const explicit = l.map((x) => /^carry:(.+)$/.exec(x)?.[1]).find((x) => CARRY_CLASSES.includes(x));
|
|
51
|
+
if (explicit) return explicit;
|
|
52
|
+
if (l.includes("needs-human:external")) return "external";
|
|
53
|
+
if (l.some((x) => POST_DEPLOY_LABELS.includes(x))) return "post-deploy";
|
|
54
|
+
if (l.includes("wont-do") || l.includes("wontfix")) return "wont-do";
|
|
55
|
+
return "engineering";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Ship pre-flight over the open issues of a milestone. Each item: `{number, labels, target, dependsOn?}` where `target` is the
|
|
60
|
+
* milestone it is carried to. `onBase(n)` says whether dependency `n` is on the target base (default: yes).
|
|
61
|
+
* Returns `{ok, missing:[{issue, why}], carry:[{issue, class, target, demote}], mustNotDemote:[issue]}`. An issue that is
|
|
62
|
+
* external, flag/legal-gated or depends on an issue that is not on the base is never demoted to fast lane.
|
|
63
|
+
*/
|
|
64
|
+
export function preflightCarryOver(openIssues, { onBase = () => true } = {}) {
|
|
65
|
+
const missing = [], carry = [], mustNotDemote = [];
|
|
66
|
+
for (const it of arr(openIssues)) {
|
|
67
|
+
const n = it?.number, cls = classifyCarryOver(it), target = String(it?.target ?? "").trim();
|
|
68
|
+
if (!target) { missing.push({ issue: n, why: "no target milestone" }); continue; }
|
|
69
|
+
const gated = names(it.labels).some((x) => GATED_LABELS.includes(x));
|
|
70
|
+
const offBase = arr(it.dependsOn).some((d) => { try { return onBase(d) === false; } catch { return false; } });
|
|
71
|
+
const blocked = cls === "external" || gated || offBase;
|
|
72
|
+
if (blocked) mustNotDemote.push(n);
|
|
73
|
+
carry.push({ issue: n, class: cls, target, demote: !blocked });
|
|
74
|
+
}
|
|
75
|
+
return { ok: missing.length === 0, missing, carry, mustNotDemote };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Open milestone `{title, state, open, closed, description}` with a shipped/deployed record and open issues left. */
|
|
79
|
+
export function shippedWithOpen(ms) {
|
|
80
|
+
const s = parseLifecycle(ms?.description)?.state;
|
|
81
|
+
return ms?.state !== "closed" && ["shipped:staging", "deployed:default"].includes(s) && Number(ms?.open) > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Open milestone with no open issues that is still not closed. */
|
|
85
|
+
export const emptyButOpen = (ms) => ms?.state !== "closed" && Number(ms?.open) === 0;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Advance one milestone to `to` (never backwards), recording `sha` (the merge commit) when given. Closes it when `closeOn` is
|
|
89
|
+
* reached and no open issue is left. `deployed(sha)` (optional) reports whether the shipped commit is on the default branch;
|
|
90
|
+
* it promotes `shipped:staging` to `deployed:default`. Returns `{state, closed}` or null when the milestone is not found.
|
|
91
|
+
*/
|
|
92
|
+
export async function advanceMilestone({ gh, repo, title, to = null, sha = null, closeOn = "default", deployed = null }) {
|
|
93
|
+
const ms = await readMilestone({ gh, repo, title });
|
|
94
|
+
if (!ms) return null;
|
|
95
|
+
const before = parseLifecycle(ms.description);
|
|
96
|
+
let state = to ? advanceState(before?.state, to) : before?.state ?? "building";
|
|
97
|
+
const rsha = sha ?? before?.sha ?? null;
|
|
98
|
+
if (state === "shipped:staging" && rsha && deployed) { try { if (await deployed(rsha)) state = "deployed:default"; } catch { /* not provably deployed */ } }
|
|
99
|
+
let closed = false;
|
|
100
|
+
if (closeDue(state, closeOn)) {
|
|
101
|
+
const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "1", "--json", "number"])) || "[]");
|
|
102
|
+
if (!arr(open).length) { state = "closed"; closed = true; }
|
|
103
|
+
}
|
|
104
|
+
const next = { state, ...(rsha ? { sha: rsha } : {}) };
|
|
105
|
+
if (!before || before.state !== next.state || before.sha !== next.sha) {
|
|
106
|
+
await gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderLifecycle(ms.description, next)}`, ...(closed ? ["-f", "state=closed"] : [])]);
|
|
107
|
+
}
|
|
108
|
+
return { state, closed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The orchestrate `lifecycle` seam: `onBatchEnd(titles)` records `shipped:staging` for each milestone the batch merged into once it has no open issue, so an
|
|
113
|
+
* orchestrate-driven milestone closes once `close_on` is reached. Errors are the caller's to report.
|
|
114
|
+
*/
|
|
115
|
+
export function makeLifecycle({ gh, git = null, repo, closeOn = "default", defaultBranch = "main" }) {
|
|
116
|
+
const deployed = git ? async (sha) => { try { await git(["merge-base", "--is-ancestor", sha, `origin/${defaultBranch}`]); return true; } catch { return false; } } : null;
|
|
117
|
+
return {
|
|
118
|
+
async onBatchEnd(titles) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const title of titles) {
|
|
121
|
+
// D3: only a milestone with no open issue left is shipped by the batch; otherwise it is re-evaluated as is.
|
|
122
|
+
const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", title, "--state", "open", "--limit", "1", "--json", "number"])) || "[]");
|
|
123
|
+
const r = await advanceMilestone({ gh, repo, title, to: arr(open).length ? null : "shipped:staging", closeOn, deployed });
|
|
124
|
+
if (r) out.push({ title, ...r });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseLifecycle, renderLifecycle, advanceState, parseCloseOn, closeDue, classifyCarryOver, preflightCarryOver, shippedWithOpen, emptyButOpen, advanceMilestone, makeLifecycle } from "./lifecycle.mjs";
|
|
4
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
5
|
+
import { renderSummary, parseSummary } from "./drift.mjs";
|
|
6
|
+
|
|
7
|
+
const fakeGh = (ms, openIssues = []) => {
|
|
8
|
+
const calls = [];
|
|
9
|
+
const gh = async (args) => {
|
|
10
|
+
calls.push(args);
|
|
11
|
+
if (args[0] === "api" && args[1].includes("/milestones?")) return JSON.stringify([{ number: 3, title: "M", description: ms.description }]);
|
|
12
|
+
if (args[0] === "issue") return JSON.stringify(openIssues);
|
|
13
|
+
if (args[1] === "-X") { ms.description = args.find((a) => a.startsWith("description=")).slice(12); }
|
|
14
|
+
return "";
|
|
15
|
+
};
|
|
16
|
+
return { gh, calls };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("forge#4657 lifecycle block", () => {
|
|
20
|
+
it("round-trips, keeps surrounding text and the drift block", () => {
|
|
21
|
+
const d = renderSummary("intro", { v: 1, merged: [] });
|
|
22
|
+
const r = renderLifecycle(d, { state: "shipped:staging", sha: "abc" });
|
|
23
|
+
assert.equal(parseLifecycle(r).state, "shipped:staging");
|
|
24
|
+
assert.ok(r.startsWith("intro") && parseSummary(r));
|
|
25
|
+
assert.equal(parseLifecycle(renderLifecycle(r, { state: "closed" })).state, "closed");
|
|
26
|
+
assert.equal(parseLifecycle("no block"), null);
|
|
27
|
+
});
|
|
28
|
+
it("only moves forward", () => {
|
|
29
|
+
assert.equal(advanceState("deployed:default", "shipped:staging"), "deployed:default");
|
|
30
|
+
assert.equal(advanceState(undefined, "shipped:staging"), "shipped:staging");
|
|
31
|
+
});
|
|
32
|
+
it("closeDue honours close_on (default: default)", () => {
|
|
33
|
+
assert.equal(parseCloseOn(undefined), "default");
|
|
34
|
+
assert.equal(closeDue("shipped:staging", "staging"), true);
|
|
35
|
+
assert.equal(closeDue("shipped:staging"), false);
|
|
36
|
+
assert.equal(closeDue("deployed:default"), true);
|
|
37
|
+
assert.equal(closeDue("closed", "staging"), false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("forge#4657 carry-over", () => {
|
|
42
|
+
it("classifies from labels", () => {
|
|
43
|
+
assert.equal(classifyCarryOver({ labels: ["needs-human:external"] }), "external");
|
|
44
|
+
assert.equal(classifyCarryOver({ labels: [{ name: "awaiting-promotion" }] }), "post-deploy");
|
|
45
|
+
assert.equal(classifyCarryOver({ labels: ["wont-do"] }), "wont-do");
|
|
46
|
+
assert.equal(classifyCarryOver({ labels: ["carry:post-deploy"] }), "post-deploy");
|
|
47
|
+
assert.equal(classifyCarryOver({ labels: [] }), "engineering");
|
|
48
|
+
});
|
|
49
|
+
it("requires a target milestone and never demotes external, gated or off-base-dependent issues", () => {
|
|
50
|
+
const r = preflightCarryOver([
|
|
51
|
+
{ number: 1, labels: [], target: "" },
|
|
52
|
+
{ number: 2, labels: ["needs-human:external"], target: "M2" },
|
|
53
|
+
{ number: 3, labels: ["legal-gated"], target: "M2" },
|
|
54
|
+
{ number: 4, labels: [], target: "M2", dependsOn: [9] },
|
|
55
|
+
{ number: 5, labels: [], target: "M2", dependsOn: [8] },
|
|
56
|
+
], { onBase: (n) => n !== 9 });
|
|
57
|
+
assert.equal(r.ok, false);
|
|
58
|
+
assert.deepEqual(r.missing, [{ issue: 1, why: "no target milestone" }]);
|
|
59
|
+
assert.deepEqual(r.mustNotDemote, [2, 3, 4]);
|
|
60
|
+
assert.deepEqual(r.carry.filter((c) => c.demote).map((c) => c.issue), [5]);
|
|
61
|
+
assert.equal(preflightCarryOver([]).ok, true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("forge#4657 pipeline-status classifiers", () => {
|
|
66
|
+
it("SHIPPED_WITH_OPEN and EMPTY_BUT_OPEN", () => {
|
|
67
|
+
const d = renderLifecycle("", { state: "shipped:staging" });
|
|
68
|
+
assert.equal(shippedWithOpen({ state: "open", open: 2, description: d }), true);
|
|
69
|
+
assert.equal(shippedWithOpen({ state: "open", open: 2, description: "" }), false);
|
|
70
|
+
assert.equal(emptyButOpen({ state: "open", open: 0 }), true);
|
|
71
|
+
assert.equal(emptyButOpen({ state: "closed", open: 0 }), false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("forge#4657 advanceMilestone", () => {
|
|
76
|
+
it("records shipped:staging and keeps the milestone open when close_on=default", async () => {
|
|
77
|
+
const ms = { description: "" }, { gh } = fakeGh(ms);
|
|
78
|
+
assert.deepEqual(await advanceMilestone({ gh, repo: "o/r", title: "M", to: "shipped:staging", sha: "abc" }), { state: "shipped:staging", closed: false });
|
|
79
|
+
assert.equal(parseLifecycle(ms.description).sha, "abc");
|
|
80
|
+
});
|
|
81
|
+
it("closes at shipped:staging when close_on=staging and no open issue is left", async () => {
|
|
82
|
+
const ms = { description: "" }, { gh, calls } = fakeGh(ms);
|
|
83
|
+
assert.equal((await advanceMilestone({ gh, repo: "o/r", title: "M", to: "shipped:staging", closeOn: "staging" })).closed, true);
|
|
84
|
+
assert.ok(calls.some((c) => c.includes("state=closed")));
|
|
85
|
+
});
|
|
86
|
+
it("does not close a milestone that still has open issues", async () => {
|
|
87
|
+
const ms = { description: "" }, { gh } = fakeGh(ms, [{ number: 1 }]);
|
|
88
|
+
assert.equal((await advanceMilestone({ gh, repo: "o/r", title: "M", to: "shipped:staging", closeOn: "staging" })).closed, false);
|
|
89
|
+
});
|
|
90
|
+
it("the orchestrate seam promotes to deployed:default once the sha is on the default branch, then closes", async () => {
|
|
91
|
+
const ms = { description: renderLifecycle("", { state: "shipped:staging", sha: "abc" }) }, { gh } = fakeGh(ms);
|
|
92
|
+
const lc = makeLifecycle({ gh, git: async () => "", repo: "o/r" });
|
|
93
|
+
assert.deepEqual(await lc.onBatchEnd(["M"]), [{ title: "M", state: "closed", closed: true }]);
|
|
94
|
+
const ms2 = { description: renderLifecycle("", { state: "shipped:staging", sha: "abc" }) }, g2 = fakeGh(ms2);
|
|
95
|
+
const lc2 = makeLifecycle({ gh: g2.gh, git: async () => { throw new Error("not ancestor"); }, repo: "o/r" });
|
|
96
|
+
assert.equal((await lc2.onBatchEnd(["M"]))[0].state, "shipped:staging");
|
|
97
|
+
});
|
|
98
|
+
it("the seam records shipped:staging for a building milestone and closes it when close_on=staging", async () => {
|
|
99
|
+
const ms = { description: "" }, { gh } = fakeGh(ms);
|
|
100
|
+
const out = await makeLifecycle({ gh, repo: "o/r", closeOn: "staging" }).onBatchEnd(["M"]);
|
|
101
|
+
assert.deepEqual(out, [{ title: "M", state: "closed", closed: true }]);
|
|
102
|
+
const ms2 = { description: "" }, g2 = fakeGh(ms2);
|
|
103
|
+
assert.deepEqual(await makeLifecycle({ gh: g2.gh, repo: "o/r" }).onBatchEnd(["M"]), [{ title: "M", state: "shipped:staging", closed: false }]);
|
|
104
|
+
assert.equal(parseLifecycle(ms2.description).state, "shipped:staging");
|
|
105
|
+
});
|
|
106
|
+
it("orchestrate calls lifecycle.onBatchEnd with the merged milestone, reports errors as a row, and is unchanged without the seam", async () => {
|
|
107
|
+
const runOne = async () => ({ terminalReason: "merged", pr: 5, milestone: "M", lane: "milestone/m" });
|
|
108
|
+
const calls = [], rows = [];
|
|
109
|
+
const res = await orchestrate({ issues: [1], parallel: 1, runOne, readBody: async () => "", onUpdate: (r) => rows.push(r), lifecycle: { onBatchEnd: async (t) => { calls.push(t); } } });
|
|
110
|
+
assert.deepEqual(calls, [["M"]]);
|
|
111
|
+
assert.equal(res[0].outcome, "merged");
|
|
112
|
+
const rows2 = [];
|
|
113
|
+
const res2 = await orchestrate({ issues: [1], parallel: 1, runOne, readBody: async () => "", onUpdate: (r) => rows2.push(r), lifecycle: { onBatchEnd: async () => { throw new Error("boom"); } } });
|
|
114
|
+
assert.equal(res2[0].outcome, "merged");
|
|
115
|
+
assert.ok(rows2.some((r) => r.outcome === "lifecycle" && /boom/.test(r.detail)));
|
|
116
|
+
assert.equal((await orchestrate({ issues: [1], parallel: 1, runOne, readBody: async () => "" }))[0].outcome, "merged");
|
|
117
|
+
});
|
|
118
|
+
});
|
package/bin/lean/light.mjs
CHANGED
|
@@ -54,7 +54,7 @@ const REPRO_RE = /steps to reproduce|\brepro(duction|duce)?\b|\bexpected[ _-]+(b
|
|
|
54
54
|
const FENCE_RE = /^\s*(```|~~~)/m;
|
|
55
55
|
const DECISION_LABEL_RE = /^(type[:/]\s*)?(decision|needs-decision|product-decision|rfc|adr)$/i;
|
|
56
56
|
const DECISION_TITLE_RE = /^\s*(decision|rfc|adr)[(:]/i;
|
|
57
|
-
const POST_DEPLOY_LABELS = new Set(["post-deploy", "awaiting-promotion"]);
|
|
57
|
+
const POST_DEPLOY_LABELS = new Set(["post-deploy", "awaiting-promotion", "awaiting-due"]);
|
|
58
58
|
|
|
59
59
|
/** Risk details for `files`: a built-in risk word or a declared review.risk_paths glob. */
|
|
60
60
|
function riskDetails(files, riskPaths) {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Milestone branch sync (#4663). No LLM. `syncMilestone` merges a base into `milestone/{slug}` on demand:
|
|
5
|
+
* - a milestone whose lifecycle is past `building` (shipped:*, deployed:default, closed) is never synced (`isSyncable`);
|
|
6
|
+
* - a base already merged in is `up-to-date`; the caller names why it syncs (`reason`), never "every base move";
|
|
7
|
+
* - conflicts go through the lane base-merge resolver (conflictres.mjs `resolveBaseConflicts`, forge.yaml `lean.base_merge_*`);
|
|
8
|
+
* - an unprotected branch gets a plain push; a protected one (branch protection or a ruleset, or a push rejected by one)
|
|
9
|
+
* gets ONE long-lived sync PR (`sync/milestone-{slug}`) whose head is force-pushed in place (with lease) while the
|
|
10
|
+
* FORGE:MILESTONE-SYNC marker in its body (base sha + time) debounces: same base => `unchanged`, inside the window =>
|
|
11
|
+
* `debounced` (a `ship` sync is never debounced). Auto-merge is armed; a CLEAN PR (auto-merge refused) is merged directly.
|
|
12
|
+
* Never throws; resolves {status, ...}.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
18
|
+
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
19
|
+
import { parseLifecycle } from "./lifecycle.mjs";
|
|
20
|
+
import { parseMinAge } from "./prefilter.mjs";
|
|
21
|
+
import { DEFAULT_FEATURE_PATTERN, milestoneSlug, readBranches } from "./lane.mjs";
|
|
22
|
+
|
|
23
|
+
export const SYNC_REASONS = ["ship", "batch", "conflict-risk", "manual"];
|
|
24
|
+
export const DEFAULT_DEBOUNCE_MS = 10 * 60 * 1000;
|
|
25
|
+
export const SYNC_MARKER = "FORGE:MILESTONE-SYNC";
|
|
26
|
+
const MARKER_RE = new RegExp(`<!--\\s*${SYNC_MARKER}\\s+(\\{[\\s\\S]*?\\})\\s*-->`);
|
|
27
|
+
const REJECTED_RE = /GH006|GH013|protected branch|repository rule|rule violations/i;
|
|
28
|
+
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
29
|
+
|
|
30
|
+
/** Only a milestone that is open and still `building` (or has no lifecycle record) may be synced or picked active. */
|
|
31
|
+
export function isSyncable(ms) {
|
|
32
|
+
const state = parseLifecycle(ms?.description)?.state;
|
|
33
|
+
return String(ms?.state ?? "open") !== "closed" && (!state || state === "building");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** forge.yaml `milestones.sync_debounce` ("10m", "1h"; default 10m) in ms. */
|
|
37
|
+
export function readSyncDebounce(cwd = process.cwd()) {
|
|
38
|
+
try { return parseMinAge(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.milestones?.sync_debounce) ?? DEFAULT_DEBOUNCE_MS; } catch { return DEFAULT_DEBOUNCE_MS; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when branch protection or a repository ruleset applies to `branch`; each source fails independently to false. */
|
|
42
|
+
export async function isProtectedBranch({ gh, repo, branch }) {
|
|
43
|
+
const safe = async (f) => { try { return await f(); } catch { return false; } };
|
|
44
|
+
return (await safe(async () => String(await gh(["api", `repos/${repo}/branches/${branch}`, "--jq", ".protected"])).trim() === "true"))
|
|
45
|
+
|| (await safe(async () => Number(String(await gh(["api", `repos/${repo}/rules/branches/${branch}`, "--jq", "length"])).trim()) > 0));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const syncMarker = (rec) => `<!-- ${SYNC_MARKER} ${JSON.stringify(rec)} -->`;
|
|
49
|
+
const readMarker = (body) => { try { return JSON.parse(MARKER_RE.exec(String(body ?? ""))?.[1]); } catch { return null; } };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {{gh:Function, git:(args:string[], o?:{cwd?:string})=>Promise<string>, repo:string, slug:string, base:string,
|
|
53
|
+
* branch?:string, cfg?:object, reason:string, now?:number, debounceMs?:number, tmp?:string}} p
|
|
54
|
+
* @returns {Promise<{status:"synced"|"pr-created"|"pr-updated"|"up-to-date"|"unchanged"|"debounced"|"shipped"|"conflict"|"refused"|"error", pr?:number, base?:string, paths?:string[], detail?:string}>}
|
|
55
|
+
*/
|
|
56
|
+
export async function syncMilestone({ gh, git, repo, slug, base, branch = `milestone/${slug}`, cfg = {}, reason, now = Date.now(), debounceMs = DEFAULT_DEBOUNCE_MS, tmp = tmpdir() }) {
|
|
57
|
+
let wt = null, baseSha;
|
|
58
|
+
try {
|
|
59
|
+
if (!SYNC_REASONS.includes(reason)) return { status: "refused", detail: `sync is on demand: reason must be one of ${SYNC_REASONS.join("|")}` };
|
|
60
|
+
const list = JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=all&per_page=100`])) || "[]");
|
|
61
|
+
const ms = (Array.isArray(list) ? list : []).find((m) => milestoneSlug(m?.title) === slug);
|
|
62
|
+
if (!ms) return { status: "refused", detail: `no milestone with slug ${slug}` };
|
|
63
|
+
if (!isSyncable(ms)) return { status: "shipped", detail: `milestone is ${parseLifecycle(ms.description)?.state ?? ms.state}: never synced` };
|
|
64
|
+
await git(["fetch", "origin", base, branch]);
|
|
65
|
+
baseSha = String(await git(["rev-parse", `origin/${base}`])).trim();
|
|
66
|
+
try { await git(["merge-base", "--is-ancestor", `origin/${base}`, `origin/${branch}`]); return { status: "up-to-date", base: baseSha }; } catch { /* behind */ }
|
|
67
|
+
|
|
68
|
+
const syncBranch = `sync/milestone-${slug}`;
|
|
69
|
+
const protectedBranch = await isProtectedBranch({ gh, repo, branch });
|
|
70
|
+
let pr = null;
|
|
71
|
+
if (protectedBranch) {
|
|
72
|
+
pr = JSON.parse(String(await gh(["pr", "list", "-R", repo, "--head", syncBranch, "--base", branch, "--state", "open", "--json", "number,body"])) || "[]")[0] ?? null;
|
|
73
|
+
const rec = readMarker(pr?.body);
|
|
74
|
+
if (rec?.base === baseSha) return { status: "unchanged", pr: pr.number, base: baseSha };
|
|
75
|
+
if (rec && reason !== "ship" && now - Number(rec.at) < debounceMs) return { status: "debounced", pr: pr.number, base: baseSha };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
wt = mkdtempSync(join(tmp, "milestonesync-"));
|
|
79
|
+
await git(["worktree", "add", "--detach", wt, `origin/${branch}`]);
|
|
80
|
+
const g = (a) => git(a, { cwd: wt });
|
|
81
|
+
const ident = String(await g(["config", "user.email"]).catch(() => "")).trim() ? [] : ["-c", "user.name=ForgeDock", "-c", "user.email=forgedock@users.noreply.github.com"];
|
|
82
|
+
try { await g([...ident, "merge", "--no-edit", `origin/${base}`]); } catch {
|
|
83
|
+
const files = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=U"]).catch(() => ""));
|
|
84
|
+
const r = files.length ? await resolveBaseConflicts({ git: g, cwd: wt, files, cfg }) : { remaining: ["(unknown files)"] };
|
|
85
|
+
if (r.remaining.length) { await g(["merge", "--abort"]).catch(() => {}); return { status: "conflict", base: baseSha, paths: r.remaining }; }
|
|
86
|
+
await g([...ident, "commit", "--no-edit"]);
|
|
87
|
+
}
|
|
88
|
+
const head = String(await g(["rev-parse", "HEAD"])).trim();
|
|
89
|
+
|
|
90
|
+
if (!protectedBranch) {
|
|
91
|
+
try { await git(["push", "origin", `${head}:refs/heads/${branch}`]); return { status: "synced", base: baseSha }; } catch (e) {
|
|
92
|
+
if (!REJECTED_RE.test(String(e?.stderr ?? e?.message ?? e))) throw e; // a rule the probe could not see: take the PR path
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const old = lines(await git(["ls-remote", "origin", `refs/heads/${syncBranch}`]).catch(() => ""))[0]?.split(/\s+/)[0] ?? "";
|
|
96
|
+
await git(["push", `--force-with-lease=refs/heads/${syncBranch}:${old}`, "origin", `${head}:refs/heads/${syncBranch}`]);
|
|
97
|
+
const body = `Sync of \`${branch}\` with \`${base}\` (${reason}). One PR per milestone: its head is updated in place when the base moves.\n\n${syncMarker({ v: 1, base: baseSha, at: now })}`;
|
|
98
|
+
let status = "pr-updated";
|
|
99
|
+
if (pr) await gh(["pr", "edit", String(pr.number), "-R", repo, "--body", body]);
|
|
100
|
+
else {
|
|
101
|
+
const url = String(await gh(["pr", "create", "-R", repo, "--base", branch, "--head", syncBranch, "--title", `chore(milestone): sync ${branch} with ${base}`, "--body", body])).trim();
|
|
102
|
+
pr = { number: Number(/(\d+)\s*$/.exec(url)?.[1]) || 0 }; status = "pr-created";
|
|
103
|
+
}
|
|
104
|
+
try { await gh(["pr", "merge", String(pr.number), "-R", repo, "--auto", "--merge"]); } catch {
|
|
105
|
+
await gh(["pr", "merge", String(pr.number), "-R", repo, "--merge"]).catch(() => {}); // CLEAN: auto-merge is refused, merge now; a blocked one stays open for its checks
|
|
106
|
+
}
|
|
107
|
+
return { status, pr: pr.number, base: baseSha };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { status: "error", ...(baseSha ? { base: baseSha } : {}), detail: String(e?.message ?? e).split("\n")[0].slice(0, 200) };
|
|
110
|
+
} finally {
|
|
111
|
+
if (wt) { await git(["worktree", "remove", "--force", wt]).catch(() => {}); try { rmSync(wt, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `forgedock milestone-sync <slug> --reason ship|batch|conflict-risk|manual [--base BRANCH] [--repo owner/repo]`; resolves the exit code. */
|
|
116
|
+
export async function runMilestoneSyncCli(argv, deps = {}) {
|
|
117
|
+
const val = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
|
|
118
|
+
const slug = argv[0] && !argv[0].startsWith("--") ? argv[0] : null;
|
|
119
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
120
|
+
const repo = val("--repo") ?? deps.repo;
|
|
121
|
+
const br = readBranches(cwd);
|
|
122
|
+
const base = val("--base") ?? br.staging ?? br.default;
|
|
123
|
+
if (!slug || !repo || !base) throw new Error("usage: forgedock milestone-sync <slug> --reason ship|batch|conflict-risk|manual --repo owner/repo [--base BRANCH]");
|
|
124
|
+
const io = deps.io ?? (await import("./effects.mjs")).makeLeanIo({ repo });
|
|
125
|
+
const { readBaseMerge } = await import("../engine-cli.mjs");
|
|
126
|
+
const res = await syncMilestone({ gh: io.gh, git: (a, o = {}) => io.git(a, { cwd: o.cwd ?? cwd }), repo, slug, base,
|
|
127
|
+
branch: String(br.featurePattern || DEFAULT_FEATURE_PATTERN).replace("{slug}", slug), cfg: deps.cfg ?? readBaseMerge(cwd) ?? {}, reason: val("--reason") ?? "", debounceMs: readSyncDebounce(cwd), ...(deps.now ? { now: deps.now } : {}) });
|
|
128
|
+
(deps.print ?? ((t) => console.log(t)))(`milestone-sync: milestone/${slug} <- ${base}: ${res.status}${res.pr ? ` (#${res.pr})` : ""}${res.paths ? ` conflicts: ${res.paths.join(", ")}` : ""}${res.detail ? ` (${res.detail})` : ""}`);
|
|
129
|
+
return ["conflict", "error", "refused"].includes(res.status) ? 1 : 0;
|
|
130
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4663: milestone sync. Real git (bare origin + clone), stub gh with a PR table; no network.
|
|
4
|
+
import { describe, it, beforeEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { isSyncable, syncMilestone } from "./milestonesync.mjs";
|
|
11
|
+
|
|
12
|
+
const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
|
|
13
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: ENV });
|
|
14
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
|
|
15
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
16
|
+
const MIN = 60000;
|
|
17
|
+
|
|
18
|
+
let origin, work, calls, prs, opts;
|
|
19
|
+
const gh = async (args) => {
|
|
20
|
+
calls.push(args.join(" "));
|
|
21
|
+
const a = args.join(" ");
|
|
22
|
+
if (args[0] === "api" && args[1].includes("/milestones")) return JSON.stringify([{ title: "Alpha", state: opts.msState ?? "open", description: opts.desc ?? "" }]);
|
|
23
|
+
if (args[0] === "api" && args[1].endsWith("/branches/milestone/alpha")) return opts.protected ? "true" : "false";
|
|
24
|
+
if (args[0] === "api" && args[1].includes("/rules/branches/")) return "0";
|
|
25
|
+
if (args[0] === "pr" && args[1] === "list") return JSON.stringify(prs.filter((p) => p.open));
|
|
26
|
+
if (args[0] === "pr" && args[1] === "create") { prs.push({ number: prs.length + 1, open: true, body: args[args.indexOf("--body") + 1] }); return `https://github.com/o/r/pull/${prs.length}\n`; }
|
|
27
|
+
if (args[0] === "pr" && args[1] === "edit") { prs.find((p) => p.number === Number(args[2])).body = args[args.indexOf("--body") + 1]; return ""; }
|
|
28
|
+
if (args[0] === "pr" && args[1] === "merge") { if (a.includes("--auto") && opts.clean) throw new Error("auto-merge not allowed: clean status"); return ""; }
|
|
29
|
+
throw new Error(`unexpected gh ${a}`);
|
|
30
|
+
};
|
|
31
|
+
const git = async (args, o = {}) => execFileSync("git", args, { cwd: o.cwd ?? work, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: ENV });
|
|
32
|
+
const run = (extra = {}) => syncMilestone({ gh, git, repo: "o/r", slug: "alpha", base: "staging", reason: "batch", cfg: {}, tmp: tmpDir("msync-tmp-"), ...extra });
|
|
33
|
+
const moveBase = (n) => { sh(work, "checkout", "-q", "staging"); put(work, { [`f${n}.txt`]: `${n}\n` }); commit(work, `base ${n}`); sh(work, "push", "-q", "origin", "staging"); };
|
|
34
|
+
const remote = (...a) => sh(origin, ...a).trim();
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
const root = tmpDir("msync-");
|
|
38
|
+
origin = join(root, "origin.git"); work = join(root, "work");
|
|
39
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "staging", origin]);
|
|
40
|
+
execFileSync("git", ["clone", "-q", origin, work], { env: ENV, stdio: "ignore" });
|
|
41
|
+
sh(work, "checkout", "-q", "-b", "staging");
|
|
42
|
+
put(work, { "registry.txt": "a\nb\n", "other.txt": "x\n" }); commit(work, "init"); sh(work, "push", "-q", "origin", "staging");
|
|
43
|
+
sh(work, "checkout", "-q", "-b", "milestone/alpha"); put(work, { "m.txt": "m\n" }); commit(work, "m"); sh(work, "push", "-q", "origin", "milestone/alpha");
|
|
44
|
+
calls = []; prs = []; opts = {};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("syncMilestone (#4663)", () => {
|
|
48
|
+
it("protected branch: one sync PR with auto-merge, the milestone branch is not pushed", async () => {
|
|
49
|
+
opts.protected = true; moveBase(1);
|
|
50
|
+
const before = remote("rev-parse", "milestone/alpha");
|
|
51
|
+
const r = await run();
|
|
52
|
+
assert.equal(r.status, "pr-created");
|
|
53
|
+
assert.equal(remote("rev-parse", "milestone/alpha"), before);
|
|
54
|
+
assert.match(remote("log", "--format=%s", "sync/milestone-alpha"), /base 1/);
|
|
55
|
+
assert.ok(calls.some((c) => c.startsWith("pr create") && c.includes("--head sync/milestone-alpha") && c.includes("--base milestone/alpha")));
|
|
56
|
+
assert.ok(calls.some((c) => c.startsWith("pr merge 1") && c.includes("--auto")));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("protected CLEAN PR: falls back to a direct merge when auto-merge is refused", async () => {
|
|
60
|
+
opts.protected = true; opts.clean = true; moveBase(1);
|
|
61
|
+
assert.equal((await run()).status, "pr-created");
|
|
62
|
+
assert.ok(calls.some((c) => c.startsWith("pr merge 1") && !c.includes("--auto")));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("a fast-moving base produces one sync PR, never closed or reopened", async () => {
|
|
66
|
+
opts.protected = true;
|
|
67
|
+
moveBase(1); assert.equal((await run({ now: 0 })).status, "pr-created");
|
|
68
|
+
const outcomes = [];
|
|
69
|
+
for (let i = 2; i <= 5; i++) { moveBase(i); outcomes.push((await run({ now: i * MIN })).status); }
|
|
70
|
+
assert.deepEqual(outcomes, ["debounced", "debounced", "debounced", "debounced"]);
|
|
71
|
+
assert.equal((await run({ now: 5 * MIN })).status, "debounced");
|
|
72
|
+
const r = await run({ now: 11 * MIN });
|
|
73
|
+
assert.equal(r.status, "pr-updated");
|
|
74
|
+
assert.equal(r.pr, 1);
|
|
75
|
+
assert.match(remote("log", "--format=%s", "sync/milestone-alpha"), /base 5/);
|
|
76
|
+
assert.equal(calls.filter((c) => c.startsWith("pr create")).length, 1);
|
|
77
|
+
assert.equal(calls.filter((c) => /^pr (close|reopen)/.test(c)).length, 0);
|
|
78
|
+
assert.equal(prs.length, 1);
|
|
79
|
+
assert.equal((await run({ now: 30 * MIN })).status, "unchanged");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("a ship sync is not debounced", async () => {
|
|
83
|
+
opts.protected = true;
|
|
84
|
+
moveBase(1); await run({ now: 0 }); moveBase(2);
|
|
85
|
+
assert.equal((await run({ now: MIN, reason: "ship" })).status, "pr-updated");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("refuses an unstated or unknown reason", async () => {
|
|
89
|
+
moveBase(1);
|
|
90
|
+
assert.equal((await run({ reason: "base-moved" })).status, "refused");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("never syncs a shipped, deployed or closed milestone", async () => {
|
|
94
|
+
moveBase(1);
|
|
95
|
+
for (const o of [{ desc: '<!-- FORGE:LIFECYCLE {"v":1,"state":"shipped:staging"} -->' }, { desc: '<!-- FORGE:LIFECYCLE {"v":1,"state":"deployed:default"} -->' }, { msState: "closed" }]) {
|
|
96
|
+
opts = o; calls = [];
|
|
97
|
+
assert.equal((await run()).status, "shipped");
|
|
98
|
+
assert.ok(!calls.some((c) => c.startsWith("pr ")));
|
|
99
|
+
}
|
|
100
|
+
assert.equal(isSyncable({ state: "open", description: '<!-- FORGE:LIFECYCLE {"v":1,"state":"building"} -->' }), true);
|
|
101
|
+
assert.equal(isSyncable({ state: "open", description: '<!-- FORGE:LIFECYCLE {"v":1,"state":"shipped:staging"} -->' }), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("unprotected branch: a plain push to the milestone branch, no PR", async () => {
|
|
105
|
+
moveBase(1);
|
|
106
|
+
assert.equal((await run()).status, "synced");
|
|
107
|
+
assert.match(remote("log", "--format=%s", "milestone/alpha"), /base 1/);
|
|
108
|
+
assert.ok(!calls.some((c) => c.startsWith("pr ")));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("up to date: nothing to do", async () => {
|
|
112
|
+
assert.equal((await run()).status, "up-to-date");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("append-only registry conflicts are resolved with the base-merge resolver", async () => {
|
|
116
|
+
sh(work, "checkout", "-q", "milestone/alpha"); put(work, { "registry.txt": "a\nb\nmilestone-row\n" }); commit(work, "reg m"); sh(work, "push", "-q", "origin", "milestone/alpha");
|
|
117
|
+
sh(work, "checkout", "-q", "staging"); put(work, { "registry.txt": "a\nb\nbase-row\n" }); commit(work, "reg base"); sh(work, "push", "-q", "origin", "staging");
|
|
118
|
+
const r = await run({ cfg: { unionGlobs: ["registry.txt"] } });
|
|
119
|
+
assert.equal(r.status, "synced");
|
|
120
|
+
const text = remote("show", "milestone/alpha:registry.txt");
|
|
121
|
+
assert.ok(text.includes("milestone-row") && text.includes("base-row"));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("a conflict outside the resolver globs leaves the branch untouched", async () => {
|
|
125
|
+
sh(work, "checkout", "-q", "milestone/alpha"); put(work, { "other.txt": "m\n" }); commit(work, "o m"); sh(work, "push", "-q", "origin", "milestone/alpha");
|
|
126
|
+
sh(work, "checkout", "-q", "staging"); put(work, { "other.txt": "b\n" }); commit(work, "o b"); sh(work, "push", "-q", "origin", "staging");
|
|
127
|
+
const before = remote("rev-parse", "milestone/alpha");
|
|
128
|
+
const r = await run({ cfg: { unionGlobs: ["registry.txt"] } });
|
|
129
|
+
assert.equal(r.status, "conflict");
|
|
130
|
+
assert.deepEqual(r.paths, ["other.txt"]);
|
|
131
|
+
assert.equal(remote("rev-parse", "milestone/alpha"), before);
|
|
132
|
+
});
|
|
133
|
+
});
|