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,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
|
+
});
|