FN-5788: add group promotion gate to merge coordinator
Add lifecycle wiring so branch-group promotion respects group-level auto-merge gating. - Extend merge coordinator/merger flow to apply group promotion precedence and pause/override reasons. - Hook the group-promotion coordinator into engine lifecycle startup. - Add reliability and coordinator coverage for branch group promotion gating behavior. - Add release changeset and update architecture/AGENTS backstop notes. Files changed: .changeset/fn-5788-branch-group-promotion-gate.md | 5 + AGENTS.md | 1 + docs/architecture.md | 3 +- .../src/__tests__/group-merge-coordinator.test.ts | 95 ++++++++++++++- .../branch-group-promotion-gate.test.ts | 129 +++++++++++++++++++++ packages/engine/src/group-merge-coordinator.ts | 46 +++++--- packages/engine/src/index.ts | 2 + packages/engine/src/merger.ts | 34 +++--- 8 files changed, 283 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-5788 Fusion-Task-Lineage: 5930f662-908e-4cf0-a0d4-68b2f4360074
This commit is contained in:
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
|
||||
import { evaluateBranchGroupPromotion, resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
@@ -24,6 +24,99 @@ afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("evaluateBranchGroupPromotion", () => {
|
||||
const baseGroup = {
|
||||
id: "BG-1",
|
||||
branchName: "fusion/groups/planning-x",
|
||||
autoMerge: true,
|
||||
status: "open" as const,
|
||||
};
|
||||
|
||||
const baseSettings: {
|
||||
autoMerge: boolean;
|
||||
globalPause: boolean;
|
||||
enginePaused: boolean;
|
||||
} = {
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
};
|
||||
|
||||
it("returns eligible when pauses are off and automerge resolves true", () => {
|
||||
const decision = evaluateBranchGroupPromotion({
|
||||
group: baseGroup,
|
||||
settings: baseSettings,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
eligible: true,
|
||||
reason: "eligible",
|
||||
groupAutoMerge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns group-automerge-disabled when group autoMerge is false", () => {
|
||||
const decision = evaluateBranchGroupPromotion({
|
||||
group: { ...baseGroup, autoMerge: false },
|
||||
settings: baseSettings,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
eligible: false,
|
||||
reason: "group-automerge-disabled",
|
||||
groupAutoMerge: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns settings-automerge-disabled when settings autoMerge is false", () => {
|
||||
const withDefaultedGroup = evaluateBranchGroupPromotion({
|
||||
group: { ...baseGroup, autoMerge: undefined as unknown as boolean },
|
||||
settings: { ...baseSettings, autoMerge: false },
|
||||
});
|
||||
expect(withDefaultedGroup).toEqual({
|
||||
eligible: false,
|
||||
reason: "settings-automerge-disabled",
|
||||
groupAutoMerge: false,
|
||||
});
|
||||
|
||||
const withExplicitGroupTrue = evaluateBranchGroupPromotion({
|
||||
group: { ...baseGroup, autoMerge: true },
|
||||
settings: { ...baseSettings, autoMerge: false },
|
||||
});
|
||||
expect(withExplicitGroupTrue).toEqual({
|
||||
eligible: false,
|
||||
reason: "settings-automerge-disabled",
|
||||
groupAutoMerge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns global-pause before other gates", () => {
|
||||
const decision = evaluateBranchGroupPromotion({
|
||||
group: { ...baseGroup, autoMerge: true },
|
||||
settings: { ...baseSettings, globalPause: true, autoMerge: false },
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
eligible: false,
|
||||
reason: "global-pause",
|
||||
groupAutoMerge: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns engine-paused before automerge gate when global pause is off", () => {
|
||||
const decision = evaluateBranchGroupPromotion({
|
||||
group: { ...baseGroup, autoMerge: true },
|
||||
settings: { ...baseSettings, enginePaused: true, autoMerge: false },
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
eligible: false,
|
||||
reason: "engine-paused",
|
||||
groupAutoMerge: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBranchGroupMergeRouting", () => {
|
||||
it("returns null for non-shared tasks", async () => {
|
||||
const routing = await resolveBranchGroupMergeRouting({
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { type TaskStore } from "@fusion/core";
|
||||
import { aiMergeTask } from "../../merger.js";
|
||||
import { git, hasGit, makeReliabilityFixture } from "./_helpers.js";
|
||||
|
||||
async function stageMergeBranch(store: TaskStore, rootDir: string, taskId: string, fileName: string): Promise<void> {
|
||||
const task = await store.getTask(taskId);
|
||||
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||
const worktreePath = join(`${rootDir}-worktrees`, taskId.toLowerCase());
|
||||
await store.updateTask(taskId, {
|
||||
baseBranch: "",
|
||||
branch,
|
||||
column: "in-review",
|
||||
worktree: worktreePath,
|
||||
steps: (task?.steps ?? []).map((step) => ({ ...step, status: "done" as const })),
|
||||
currentStep: (task?.steps ?? []).length ?? 0,
|
||||
} as any);
|
||||
|
||||
git(rootDir, `git checkout -b ${branch}`);
|
||||
await mkdir(join(rootDir, "packages/engine/src"), { recursive: true });
|
||||
git(rootDir, `sh -c 'printf ${JSON.stringify(`export const ${fileName} = true;\n`)} > ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}'`);
|
||||
git(rootDir, `git add ${JSON.stringify(`packages/engine/src/${fileName}.ts`)}`);
|
||||
git(rootDir, `git commit -m ${JSON.stringify(`feat: add ${fileName}`)}`);
|
||||
git(rootDir, "git checkout main");
|
||||
store.enqueueMergeQueue(taskId);
|
||||
}
|
||||
|
||||
type Scenario = {
|
||||
name: string;
|
||||
groupAutoMerge?: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
expected: { effectiveEligible: boolean; reason: string; groupAutoMerge: boolean };
|
||||
};
|
||||
|
||||
function findPromotionGateEvent(store: TaskStore, groupId: string) {
|
||||
const events = store.getRunAuditEvents();
|
||||
return events.find((event) => event.mutationType === "merge:branch-group-promotion-gated" && (event.metadata as any)?.groupId === groupId);
|
||||
}
|
||||
|
||||
describe("FN-5788 reliability interactions: branch group promotion gate", () => {
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: "eligible gate emits without promoting default branch",
|
||||
groupAutoMerge: true,
|
||||
settings: { autoMerge: true },
|
||||
expected: { effectiveEligible: true, reason: "eligible", groupAutoMerge: true },
|
||||
},
|
||||
{
|
||||
name: "group-automerge-disabled when group autoMerge is false",
|
||||
groupAutoMerge: false,
|
||||
settings: { autoMerge: true },
|
||||
expected: { effectiveEligible: false, reason: "group-automerge-disabled", groupAutoMerge: false },
|
||||
},
|
||||
{
|
||||
name: "global-pause override",
|
||||
groupAutoMerge: true,
|
||||
settings: { autoMerge: true, globalPause: true },
|
||||
expected: { effectiveEligible: false, reason: "global-pause", groupAutoMerge: true },
|
||||
},
|
||||
{
|
||||
name: "engine-paused override",
|
||||
groupAutoMerge: true,
|
||||
settings: { autoMerge: true, enginePaused: true },
|
||||
expected: { effectiveEligible: false, reason: "engine-paused", groupAutoMerge: true },
|
||||
},
|
||||
{
|
||||
name: "settings-automerge-disabled override",
|
||||
groupAutoMerge: true,
|
||||
settings: { autoMerge: false, globalPause: false, enginePaused: false },
|
||||
expected: { effectiveEligible: false, reason: "settings-automerge-disabled", groupAutoMerge: true },
|
||||
},
|
||||
];
|
||||
|
||||
for (const [index, scenario] of scenarios.entries()) {
|
||||
it.skipIf(!hasGit)(scenario.name, async () => {
|
||||
const fixture = await makeReliabilityFixture({ settings: { ...scenario.settings, testMode: true } as any });
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const fileName = `fn5788Gate${index}`;
|
||||
await stageMergeBranch(store, rootDir, task.id, fileName);
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: `PS-FN5788-${index}`,
|
||||
branchName: `fusion/groups/fn-5788-gate-${index}`,
|
||||
autoMerge: scenario.groupAutoMerge,
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.updateTask(task.id, {
|
||||
branchContext: { groupId: group.id, source: "planning", assignmentMode: "shared" },
|
||||
} as any);
|
||||
|
||||
const auditSpy = vi.spyOn(store as any, "recordRunAuditEvent");
|
||||
const result = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(result.merged).toBe(true);
|
||||
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/${fileName}.ts`)).toContain(fileName);
|
||||
expect(() => git(rootDir, `git show main:packages/engine/src/${fileName}.ts`)).toThrow();
|
||||
|
||||
expect(store.getBranchGroup(group.id)?.status).toBe("open");
|
||||
|
||||
expect(findPromotionGateEvent(store, group.id)?.metadata).toEqual(expect.objectContaining({
|
||||
groupId: group.id,
|
||||
branchName: group.branchName,
|
||||
groupAutoMerge: scenario.expected.groupAutoMerge,
|
||||
effectiveEligible: scenario.expected.effectiveEligible,
|
||||
reason: scenario.expected.reason,
|
||||
}));
|
||||
|
||||
expect(auditSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "git",
|
||||
mutationType: "merge:branch-group-promotion-gated",
|
||||
target: task.id,
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
branchName: group.branchName,
|
||||
groupAutoMerge: scenario.expected.groupAutoMerge,
|
||||
effectiveEligible: scenario.expected.effectiveEligible,
|
||||
reason: scenario.expected.reason,
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user