diff --git a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts index 2ae1436a8b..7ba82cf6fa 100644 --- a/packages/dashboard/src/__tests__/routes-branch-groups.test.ts +++ b/packages/dashboard/src/__tests__/routes-branch-groups.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import type { BranchGroup, Task, TaskStore } from "@fusion/core"; -import { evaluateBranchGroupCompletion } from "@fusion/engine"; +import { evaluateBranchGroupCompletion, ProjectEngine } from "@fusion/engine"; import { createApiRoutes } from "../routes.js"; import { request as REQUEST } from "../test-request.js"; @@ -96,18 +96,72 @@ describe("branch group routes", () => { expect((store.setTaskBranchGroup as unknown as ReturnType)).toHaveBeenLastCalledWith("FN-1", null); }); - it("promotes completed groups and rejects incomplete groups", async () => { - const promoteBranchGroup = vi.fn(async () => ({ prNumber: 202, prUrl: "https://example/pr/202", prState: "open", status: "open" })); - const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; - let app = buildApp(createStore(group, completeTasks), promoteBranchGroup); - let res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); - expect(res.status).toBe(200); - expect(promoteBranchGroup).toHaveBeenCalledWith("BG-1"); - expect(res.body.prNumber).toBe(202); + it("exposes a real, callable promoteBranchGroup method on the engine class (regression guard)", () => { + // U4: the dashboard promote route reaches engine.promoteBranchGroup AS A + // METHOD. If that method ever goes missing from ProjectEngine, this fails + // instead of being silently masked by a route-level vi.fn mock. + expect(typeof (ProjectEngine.prototype as { promoteBranchGroup?: unknown }).promoteBranchGroup).toBe("function"); + }); - app = buildApp(createStore(group, tasks), promoteBranchGroup); - res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + it("promotes a completed group by reaching the real engine method (not a hand-rolled mock)", async () => { + // Drive the route through the ACTUAL ProjectEngine.promoteBranchGroup body + // bound to a stub context, so the wiring proves it reaches a real, callable + // method that delegates to the coordinator — not a fabricated vi.fn. + const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; + + const finalizedGroup: BranchGroup = { ...group, status: "finalized", prState: "merged" }; + const engineStore = { + getSettings: vi.fn(async () => ({ + autoMerge: false, + globalPause: false, + enginePaused: false, + mergeStrategy: "pull-request", + })), + getBranchGroup: vi.fn(() => finalizedGroup), + listTasksByBranchGroup: vi.fn(async () => completeTasks), + updateBranchGroup: vi.fn(() => finalizedGroup), + recordRunAuditEvent: vi.fn(async () => {}), + }; + // Minimal ProjectEngine-shaped context the real method body reads. + const engineContext = { + runtime: { getTaskStore: () => engineStore }, + config: { workingDirectory: "/tmp/project" }, + }; + // Bind the REAL method (the same one the dashboard route invokes). + const realPromote = (ProjectEngine.prototype as unknown as { + promoteBranchGroup: (this: unknown, groupId: string) => Promise>; + }).promoteBranchGroup; + const boundPromote = ((groupId: string) => + realPromote.call(engineContext, groupId)) as unknown as ReturnType; + + const app = buildApp(createStore(group, completeTasks), boundPromote); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + // already-finalized group → method short-circuits before any git work and + // returns the persisted state; what matters is the route reached the method. + expect(res.status).toBe(200); + expect(res.body.groupId).toBe("BG-1"); + expect(res.body.reason).toBe("already-finalized"); + expect(engineStore.getBranchGroup).toHaveBeenCalledWith("BG-1"); + }); + + it("rejects promotion of an incomplete group at the completion gate (no engine call)", async () => { + const realPromote = (ProjectEngine.prototype as unknown as { + promoteBranchGroup: (this: unknown, groupId: string) => Promise>; + }).promoteBranchGroup; + const promoteSpy = vi.fn((groupId: string) => realPromote.call({}, groupId)); + const app = buildApp(createStore(group, tasks), promoteSpy as unknown as ReturnType); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); expect(res.status).toBe(400); + expect(promoteSpy).not.toHaveBeenCalled(); + }); + + it("surfaces the error path when the engine lacks a promoteBranchGroup method", async () => { + // If the bridge method is missing from the resolved engine, the route's + // option callback throws "promoteBranchGroup is not available on engine". + const completeTasks = [buildTask("FN-1", group.id, true), buildTask("FN-2", group.id, true)]; + const app = buildApp(createStore(group, completeTasks), undefined); + const res = await REQUEST(app, "POST", "/api/branch-groups/BG-1/promote", JSON.stringify({}), { "content-type": "application/json" }); + expect(res.status).toBeGreaterThanOrEqual(400); }); it("route serialization and coordinator agree on landed/complete for the same fixture", async () => { diff --git a/packages/engine/src/__tests__/group-merge-coordinator.test.ts b/packages/engine/src/__tests__/group-merge-coordinator.test.ts index 4096dd6320..ff1f3e084b 100644 --- a/packages/engine/src/__tests__/group-merge-coordinator.test.ts +++ b/packages/engine/src/__tests__/group-merge-coordinator.test.ts @@ -11,6 +11,7 @@ import { promoteBranchGroup, resolveBranchGroupMergeRouting, } from "../group-merge-coordinator.js"; +import { ProjectEngine } from "../project-engine.js"; const dirs: string[] = []; @@ -336,6 +337,102 @@ describe("promoteBranchGroup", () => { }); }); +describe("ProjectEngine.promoteBranchGroup (U4 bridge method)", () => { + // The dashboard promote route calls engine.promoteBranchGroup AS A METHOD. + // These tests invoke the REAL method body bound to a minimal engine-shaped + // context, proving it resolves store/rootDir/settings and delegates to the + // standalone coordinator — without standing up a full ProjectEngine. + const realPromote = ProjectEngine.prototype.promoteBranchGroup; + + function makeGroup(overrides?: Partial) { + return { + id: "BG-1", + sourceType: "planning", + sourceId: "planning:x", + branchName: "fusion/groups/planning-x", + autoMerge: true, + prState: "none", + status: "open", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; + } + + const landedMember = (id: string, branchName: string) => ({ + id, + column: "done" as const, + mergeDetails: { + mergeConfirmed: true, + mergeTargetSource: "branch-group-integration", + mergeTargetBranch: branchName, + }, + }); + + function makeEngineContext(rootDir: string, store: unknown, settings: Record) { + const getSettingsCalls = { count: 0 }; + const fullStore = { + ...(store as Record), + getSettings: async () => { + getSettingsCalls.count += 1; + return settings; + }, + recordRunAuditEvent: async () => {}, + }; + return { + context: { + runtime: { getTaskStore: () => fullStore }, + config: { workingDirectory: rootDir }, + }, + getSettingsCalls, + }; + } + + it("resolves settings via the store and delegates to the coordinator (promotes a complete group)", async () => { + const rootDir = makeRepo(); + execSync("git checkout -b fusion/groups/planning-x", { cwd: rootDir }); + execSync("echo promoted > group.txt", { cwd: rootDir, shell: "/bin/bash" }); + execSync("git add group.txt && git commit -m group", { cwd: rootDir, shell: "/bin/bash" }); + execSync("git checkout main", { cwd: rootDir }); + + let group = makeGroup(); + const { context, getSettingsCalls } = makeEngineContext(rootDir, { + getBranchGroup: () => group, + listTasksByBranchGroup: async () => [landedMember("FN-A", group.branchName)], + updateBranchGroup: (_id: string, patch: Partial) => { + group = { ...group, ...patch }; + return group; + }, + }, { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }); + + const result = await realPromote.call(context as any, "BG-1"); + + expect(getSettingsCalls.count).toBe(1); + expect(result.promoted).toBe(true); + expect(result.reason).toBe("promoted"); + expect(group.status).toBe("finalized"); + expect(execSync("git show main:group.txt", { cwd: rootDir, encoding: "utf8" })).toContain("promoted"); + }); + + it("rejects an incomplete group at the coordinator completion gate", async () => { + const rootDir = makeRepo(); + const group = makeGroup(); + const { context } = makeEngineContext(rootDir, { + getBranchGroup: () => group, + listTasksByBranchGroup: async () => [{ id: "FN-A", column: "todo" }], + updateBranchGroup: () => { + throw new Error("should not update an incomplete group"); + }, + }, { autoMerge: true, globalPause: false, enginePaused: false, mergeStrategy: "direct", baseBranch: "main" }); + + const result = await realPromote.call(context as any, "BG-1"); + + expect(result.reason).toBe("incomplete"); + expect(result.promoted).toBe(false); + expect(() => execSync("git show main:group.txt", { cwd: rootDir })).toThrow(); + }); +}); + describe("resolveBranchGroupMergeRouting", () => { it("returns null for non-shared tasks", async () => { const routing = await resolveBranchGroupMergeRouting({ diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index cfa8d5ca20..ea77b1081b 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -27,7 +27,7 @@ import { CronRunner, createAiPromptExecutor } from "./cron-runner.js"; import type { RoutineRunner } from "./routine-runner.js"; import { aiMergeTask, sweepStaleAutostashes, VerificationError } from "./merger.js"; import { runAiMerge } from "./merger-ai.js"; -import { promoteBranchGroup } from "./group-merge-coordinator.js"; +import { promoteBranchGroup, type BranchGroupPromotionResult } from "./group-merge-coordinator.js"; import { PRIORITY_MERGE } from "./concurrency.js"; import { runtimeLog } from "./logger.js"; import type { HeartbeatTriggerScheduler } from "./agent-heartbeat.js"; @@ -924,6 +924,45 @@ export class ProjectEngine { return this.internalEnqueueMerge(taskId); } + /** + * Promote a shared branch group: merge the group branch into the integration + * branch and reconcile `prState` (completion-gated, idempotent). + * + * This is the single engine bridge method (KTD5) that the dashboard promote + * route reaches via the `promoteBranchGroup` option callback in + * `register-integrated-routers.ts`. It resolves the same store / rootDir / + * settings context the internal auto-promotion path (`attemptBranchGroupPromotion`) + * uses and delegates to the standalone coordinator function — no logic is + * duplicated here. + */ + async promoteBranchGroup(groupId: string): Promise { + const store = this.runtime.getTaskStore(); + const cwd = this.config.workingDirectory; + const settings = await store.getSettings(); + const promotionSettings = { + autoMerge: settings.autoMerge, + globalPause: settings.globalPause, + enginePaused: settings.enginePaused, + mergeStrategy: settings.mergeStrategy, + integrationBranch: settings.integrationBranch, + baseBranch: settings.baseBranch, + }; + return await promoteBranchGroup({ + store, + rootDir: cwd, + groupId, + settings: promotionSettings, + recordAudit: async (event) => { + await store.recordRunAuditEvent({ + domain: event.domain as any, + mutationType: event.mutationType, + target: event.target, + metadata: event.metadata, + } as any); + }, + }); + } + /** * Perform an AI-powered merge for a task, serialized through the merge queue. * This is the manual "merge now" path — it shares the same queue as auto-merge