FN-5782: wire branch groups into merge routing
Connect branch_group metadata through planning and merge execution for grouped integration branches. - propagate `branchGroup` and `branchGroupName` through core store types, merge metadata, and CLI task lifecycle APIs - add merge coordination that computes branch-group plans and routes grouped tasks via `group-merge-coordinator` - extend finalize-plan, merger, and reliability tests to cover grouped merge routing and integration behavior - document the architecture update and add a patch changeset for `@runfusion/fusion` Files changed: .changeset/fn-5782-branch-group-merge.md | 7 + docs/architecture.md | 2 + packages/cli/src/commands/__tests__/task-lifecycle.test.ts | 64 ++++++++- packages/cli/src/commands/task-lifecycle.ts | 4 + packages/core/src/__tests__/branch-group-store.test.ts | 20 +++ packages/core/src/__tests__/task-merge.test.ts | 64 +++++++++ packages/core/src/store.ts | 22 ++- packages/core/src/task-merge.ts | 26 +++- packages/core/src/types.ts | 2 +- packages/engine/src/__tests__/experiment-finalize-plan.test.ts | 33 ++++- packages/engine/src/__tests__/group-merge-coordinator.test.ts | 62 +++++++++ packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts | 153 +++++++++++++++++++++ packages/engine/src/experiment/finalize-plan.ts | 62 +++++++-- packages/engine/src/group-merge-coordinator.ts | 51 +++++++ packages/engine/src/index.ts | 4 + packages/engine/src/merger.ts | 47 ++++++- 16 files changed, 601 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-5782 Fusion-Task-Lineage: 0a70cfaa-2379-4955-b295-64de1f3093c8
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
|
||||
import { buildDefaultPlan, mergePlanWithUserOverrides } from "../experiment/finalize-plan.js";
|
||||
import type { BranchGroup, ExperimentSession, ExperimentSessionRecord } from "@fusion/core";
|
||||
import { buildDefaultPlan, buildTaskGroupPlan, mergePlanWithUserOverrides } from "../experiment/finalize-plan.js";
|
||||
import { ExperimentFinalizePlanError } from "../experiment/finalize-types.js";
|
||||
|
||||
function createSession(overrides: Partial<ExperimentSession> = {}): ExperimentSession {
|
||||
@@ -139,6 +139,35 @@ describe("experiment finalize plan", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a task-group finalize plan keyed to existing branch name", () => {
|
||||
const branchGroup: BranchGroup = {
|
||||
id: "BG-1",
|
||||
sourceType: "mission",
|
||||
sourceId: "SL-1",
|
||||
branchName: "fusion/groups/sl-1",
|
||||
autoMerge: false,
|
||||
prState: "none",
|
||||
status: "open",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const plan = buildTaskGroupPlan({
|
||||
branchGroup,
|
||||
memberTasks: [
|
||||
{ id: "FN-2", createdAt: "2026-01-02T00:00:00.000Z", mergeDetails: { commitSha: "c2" } },
|
||||
{ id: "FN-1", createdAt: "2026-01-01T00:00:00.000Z", mergeDetails: { commitSha: "c1" } },
|
||||
],
|
||||
integrationBranch: "main",
|
||||
mergeBaseCommit: "base",
|
||||
});
|
||||
|
||||
expect(plan.groups).toHaveLength(1);
|
||||
expect(plan.groups[0].suggestedBranchName).toBe("fusion/groups/sl-1");
|
||||
expect(plan.groups[0].runRecordIds).toEqual(["FN-1", "FN-2"]);
|
||||
expect(plan.groups[0].commits).toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("falls back baseline commit to baseline run then merge-base", () => {
|
||||
const baseline = runRecord("base", 1, 1, "keep", "baseline-commit");
|
||||
const kept = runRecord("r1", 1, 2, "keep", "c1");
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { resolveBranchGroupMergeRouting } from "../group-merge-coordinator.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
function makeRepo(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fusion-group-route-"));
|
||||
dirs.push(dir);
|
||||
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
|
||||
execSync("git config user.name test", { cwd: dir });
|
||||
execSync("git config user.email test@example.com", { cwd: dir });
|
||||
execSync("echo hi > a.txt", { cwd: dir, shell: "/bin/bash" });
|
||||
execSync("git add . && git commit -m init", { cwd: dir, stdio: "ignore", shell: "/bin/bash" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("resolveBranchGroupMergeRouting", () => {
|
||||
it("returns null for non-shared tasks", async () => {
|
||||
const routing = await resolveBranchGroupMergeRouting({
|
||||
task: { branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "per-task-derived" } },
|
||||
store: { getBranchGroup: () => null } as any,
|
||||
projectDefaultBranch: "main",
|
||||
});
|
||||
expect(routing).toBeNull();
|
||||
});
|
||||
|
||||
it("creates the group branch when missing", async () => {
|
||||
const rootDir = makeRepo();
|
||||
const branchGroup = {
|
||||
id: "BG-1",
|
||||
sourceType: "planning",
|
||||
sourceId: "planning:x",
|
||||
branchName: "fusion/groups/planning-x",
|
||||
autoMerge: false,
|
||||
prState: "none",
|
||||
status: "open",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const routing = await resolveBranchGroupMergeRouting({
|
||||
task: { branchContext: { groupId: "BG-1", source: "planning", assignmentMode: "shared" } },
|
||||
store: { getBranchGroup: () => branchGroup } as any,
|
||||
projectDefaultBranch: "main",
|
||||
rootDir,
|
||||
});
|
||||
|
||||
expect(routing?.mergeTarget.branch).toBe(branchGroup.branchName);
|
||||
const branch = execSync(`git rev-parse --verify refs/heads/${branchGroup.branchName}`, { cwd: rootDir, encoding: "utf8" }).trim();
|
||||
expect(branch).toMatch(/^[a-f0-9]{40}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
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 worktreeRoot = `${rootDir}-worktrees`;
|
||||
const worktreePath = join(worktreeRoot, 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);
|
||||
}
|
||||
|
||||
describe("FN-5782 reliability interactions: branch group merge routing", () => {
|
||||
it.skipIf(!hasGit)("routes shared grouped members to branch group integration branch and emits audit", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-SHARED", settings: { testMode: true } as any });
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
await stageMergeBranch(store, rootDir, task.id, "fn5782Shared");
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "planning",
|
||||
sourceId: "PS-FN5782",
|
||||
branchName: "fusion/groups/fn-5782-shared",
|
||||
});
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
const beforeUpdateAt = store.getBranchGroup(group.id)!.updatedAt;
|
||||
|
||||
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/fn5782Shared.ts`)).toContain("fn5782Shared");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782Shared.ts")).toThrow();
|
||||
|
||||
const updatedGroup = store.getBranchGroup(group.id)!;
|
||||
expect(updatedGroup.status).toBe("open");
|
||||
expect(updatedGroup.updatedAt).toBeGreaterThanOrEqual(beforeUpdateAt);
|
||||
expect(updatedGroup.worktreePath).toBe(join(`${rootDir}-worktrees`, task.id.toLowerCase()));
|
||||
|
||||
expect(auditSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
domain: "git",
|
||||
mutationType: "merge:branch-group-routed",
|
||||
target: task.id,
|
||||
metadata: expect.objectContaining({
|
||||
groupId: group.id,
|
||||
branchName: group.branchName,
|
||||
mergeTargetBranch: group.branchName,
|
||||
mergeTargetSource: "branch-group-integration",
|
||||
}),
|
||||
}));
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it.skipIf(!hasGit)("lands two shared members of same group onto one integration branch", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-A", settings: { testMode: true } as any });
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
const second = await store.createTask({
|
||||
id: "FN-5782-RI-B",
|
||||
title: "FN-5782-RI-B",
|
||||
description: "second member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5782-ri-b",
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
const group = store.createBranchGroup({
|
||||
sourceType: "mission",
|
||||
sourceId: "M-FN5782",
|
||||
branchName: "fusion/groups/fn-5782-multi",
|
||||
});
|
||||
|
||||
await stageMergeBranch(store, rootDir, task.id, "fn5782MemberA");
|
||||
await stageMergeBranch(store, rootDir, second.id, "fn5782MemberB");
|
||||
await store.setTaskBranchGroup(task.id, group.id);
|
||||
await store.setTaskBranchGroup(second.id, group.id);
|
||||
|
||||
const firstResult = await aiMergeTask(store, rootDir, task.id);
|
||||
const secondResult = await aiMergeTask(store, rootDir, second.id);
|
||||
expect(firstResult.merged).toBe(true);
|
||||
expect(secondResult.merged).toBe(true);
|
||||
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782MemberA.ts`)).toContain("fn5782MemberA");
|
||||
expect(git(rootDir, `git show ${group.branchName}:packages/engine/src/fn5782MemberB.ts`)).toContain("fn5782MemberB");
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782MemberA.ts")).toThrow();
|
||||
expect(() => git(rootDir, "git show main:packages/engine/src/fn5782MemberB.ts")).toThrow();
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
it.skipIf(!hasGit)("keeps ungrouped and per-task-derived members on project default without group routing audit", async () => {
|
||||
const fixture = await makeReliabilityFixture({ taskId: "FN-5782-RI-UNGROUPED", settings: { testMode: true } as any });
|
||||
|
||||
try {
|
||||
const { rootDir, store, task } = fixture;
|
||||
await stageMergeBranch(store, rootDir, task.id, "fn5782Ungrouped");
|
||||
const ungrouped = await aiMergeTask(store, rootDir, task.id);
|
||||
expect(ungrouped.merged).toBe(true);
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5782Ungrouped.ts")).toContain("fn5782Ungrouped");
|
||||
|
||||
const second = await store.createTask({
|
||||
id: "FN-5782-RI-DERIVED",
|
||||
title: "FN-5782-RI-DERIVED",
|
||||
description: "derived member",
|
||||
column: "in-review",
|
||||
baseBranch: "main",
|
||||
branch: "fusion/fn-5782-ri-derived",
|
||||
branchContext: { groupId: "BG-DERIVED", source: "planning", assignmentMode: "per-task-derived" },
|
||||
prompt: "## File Scope\n- packages/engine/src/__tests__/reliability-interactions/**/*.ts\n",
|
||||
steps: [],
|
||||
} as any);
|
||||
|
||||
await stageMergeBranch(store, rootDir, second.id, "fn5782Derived");
|
||||
const derived = await aiMergeTask(store, rootDir, second.id);
|
||||
expect(derived.merged).toBe(true);
|
||||
expect(git(rootDir, "git show main:packages/engine/src/fn5782Derived.ts")).toContain("fn5782Derived");
|
||||
|
||||
const routedEvents = store
|
||||
.getRunAuditEvents()
|
||||
.filter((event) => [task.id, second.id].includes((event.target as string) ?? "") && event.mutationType === "merge:branch-group-routed");
|
||||
expect(routedEvents).toEqual([]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}, 45_000);
|
||||
});
|
||||
Reference in New Issue
Block a user