feat(FN-3217): honor resolved merge target across completion flows

- Add merge target branch resolver contract to core types and exports
- Update task lifecycle completion path to resolve and propagate merge target branch
- Use resolved merge target in merger execution instead of stale/default branch assumptions
- Expand core and CLI tests for merge-target resolution behavior and add changeset for @runfusion/fusion

Fusion-Task-Id: FN-3217
This commit is contained in:
Fusion
2026-05-09 23:06:18 -07:00
committed by gsxdsm
parent a2c87cfb47
commit bcb79d8b57
9 changed files with 150 additions and 34 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Honor task-configured merge targets across CLI and merge completion paths, including PR creation base branch selection and merge metadata resolution.

View File

@@ -901,6 +901,7 @@ describe("processPullRequestMergeTask", () => {
title: "FN-093: Add support for creating pull requests",
body: "Automated PR for FN-093.\n\nImplement PR automation",
head: "fusion/fn-093",
base: "main",
});
expect(store.updatePrInfo).toHaveBeenCalledWith(
"FN-093",
@@ -1126,6 +1127,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1154,6 +1156,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
expect(aiMergeTask).not.toHaveBeenCalled();
});

View File

@@ -17,6 +17,7 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
/**
@@ -25,7 +26,7 @@ import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
*/
interface GitHubOperations {
findPrForBranch(params: { head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
createPr(params: { title: string; body: string; head: string }): Promise<PrInfo>;
createPr(params: { title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
getPrMergeStatus(base?: string, head?: string, number?: number): Promise<{
prInfo: PrInfo;
reviewDecision: string | null;
@@ -228,6 +229,11 @@ export async function processPullRequestMergeTask(
}
const branch = getTaskBranchName(task.id);
const settings = await store.getSettings();
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
let prInfo: PrInfo | undefined = task.prInfo;
if (!prInfo) {
@@ -245,6 +251,7 @@ export async function processPullRequestMergeTask(
title: buildPullRequestTitle(task),
body: buildPullRequestBody(task),
head: branch,
base: mergeTarget.branch,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
@@ -269,7 +276,7 @@ export async function processPullRequestMergeTask(
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
}
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
const mergeStatus = await github.getPrMergeStatus(mergeTarget.branch, branch, prInfo.number);
const refreshedPrInfo: PrInfo = {
...prInfo,
...mergeStatus.prInfo,
@@ -288,7 +295,6 @@ export async function processPullRequestMergeTask(
// immediately. `requirePrApproval` lets users keep PR mode as "open the
// PR, wait for me to approve and merge it" by holding the merge until
// reviewDecision === "APPROVED".
const settings = await store.getSettings();
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
return "waiting";

View File

@@ -1,6 +1,11 @@
import { describe, it, expect } from "vitest";
import type { StepStatus } from "../types.js";
import { getTaskCompletionBlocker, getTaskMergeBlocker, isTaskReadyForMerge } from "../task-merge.js";
import {
getTaskCompletionBlocker,
getTaskMergeBlocker,
isTaskReadyForMerge,
resolveTaskMergeTarget,
} from "../task-merge.js";
const baseTask = {
column: "in-review" as const,
@@ -16,6 +21,47 @@ const baseCompletionTask = {
blockedBy: undefined as string | undefined,
};
describe("resolveTaskMergeTarget", () => {
it("prefers task baseBranch when present", () => {
expect(resolveTaskMergeTarget({ baseBranch: "release/1.2", branchContext: undefined })).toEqual({
branch: "release/1.2",
source: "task-base-branch",
});
});
it("falls back to inherited branch context", () => {
expect(resolveTaskMergeTarget({
baseBranch: undefined,
branchContext: {
groupId: "G-1",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "develop",
},
})).toEqual({
branch: "develop",
source: "task-branch-context",
});
});
it("uses project default branch when task has no explicit target", () => {
expect(resolveTaskMergeTarget(
{ baseBranch: undefined, branchContext: undefined },
{ projectDefaultBranch: "trunk" },
)).toEqual({
branch: "trunk",
source: "project-default",
});
});
it("falls back to legacy main when no target is configured", () => {
expect(resolveTaskMergeTarget({ baseBranch: undefined, branchContext: undefined })).toEqual({
branch: "main",
source: "legacy-main",
});
});
});
describe("getTaskMergeBlocker", () => {
it("returns undefined for a clean task in review", () => {
expect(getTaskMergeBlocker(baseTask)).toBeUndefined();

View File

@@ -87,7 +87,14 @@ export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDae
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
export {
getTaskMergeBlocker,
getTaskCompletionBlocker,
isTaskReadyForMerge,
resolveTaskMergeTarget,
type MergeTargetResolution,
type MergeTargetResolverOptions,
} from "./task-merge.js";
export {
isGhAvailable,
isGhAuthenticated,

View File

@@ -20,7 +20,7 @@ import { TodoStore } from "./todo-store.js";
import { EvalStore } from "./eval-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker } from "./task-merge.js";
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
import { ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
import { createLogger } from "./logger.js";
@@ -4487,7 +4487,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return clearedIds;
}
private async collectMergeDetails(_id: string, _branch: string, task: Task, commitMessage: string): Promise<import("./types.js").MergeDetails> {
private async collectMergeDetails(
_id: string,
_branch: string,
task: Task,
commitMessage: string,
mergeTarget?: { branch: string; source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main" },
): Promise<import("./types.js").MergeDetails> {
const mergedAt = new Date().toISOString();
let commitSha: string | undefined;
let filesChanged: number | undefined;
@@ -4526,6 +4532,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mergedAt,
mergeConfirmed: true,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget?.branch,
mergeTargetSource: mergeTarget?.source,
resolutionStrategy: task.mergeDetails?.resolutionStrategy,
resolutionMethod: task.mergeDetails?.resolutionMethod,
attemptsMade: task.mergeDetails?.attemptsMade,
@@ -4541,7 +4549,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
const branch = `fusion/${id.toLowerCase()}`;
const branch = task.branch || `fusion/${id.toLowerCase()}`;
// Branch is derived from the task id (already validated at create time),
// but assert as defense-in-depth against future id-format changes.
assertSafeGitBranchName(branch);
@@ -4601,6 +4609,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
branchDeleted: false,
};
const settings = await this.getSettings();
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
// 1. Check the branch exists
const verifyBranch = await this.runGitCommand(`git rev-parse --verify "${branch}"`);
if (verifyBranch.exitCode !== 0) {
@@ -4610,6 +4624,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
mergedAt: new Date().toISOString(),
mergeConfirmed: false,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
};
await this.moveToDone(task, dir);
result.task = { ...task, column: "done" };
@@ -4617,6 +4633,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return result;
}
const checkoutTarget = await this.runGitCommand(`git checkout "${mergeTarget.branch}"`, 120_000);
if (checkoutTarget.exitCode !== 0) {
throw new Error(`Unable to checkout merge target branch '${mergeTarget.branch}' for ${id}`);
}
// 2. Merge the branch
const mergeCommitMessage = `feat(${id}): merge ${branch}`;
const merge = await this.runGitCommand(`git merge --squash "${branch}"`, 120_000);
@@ -4626,7 +4647,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (merge.exitCode === 0 && commit.exitCode === 0) {
result.merged = true;
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage);
const mergeDetails = await this.collectMergeDetails(id, branch, task, mergeCommitMessage, mergeTarget);
task.mergeDetails = mergeDetails;
Object.assign(result, mergeDetails);
} else {

View File

@@ -1,5 +1,38 @@
import type { Task, WorkflowStepResult } from "./types.js";
export interface MergeTargetResolution {
branch: string;
source: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
}
export interface MergeTargetResolverOptions {
projectDefaultBranch?: string;
legacyFallbackBranch?: string;
}
export function resolveTaskMergeTarget(
task: Pick<Task, "baseBranch" | "branchContext">,
options: MergeTargetResolverOptions = {},
): MergeTargetResolution {
const configuredBase = task.baseBranch?.trim();
if (configuredBase) {
return { branch: configuredBase, source: "task-base-branch" };
}
const inheritedBase = task.branchContext?.inheritedBaseBranch?.trim();
if (inheritedBase) {
return { branch: inheritedBase, source: "task-branch-context" };
}
const projectDefault = options.projectDefaultBranch?.trim();
if (projectDefault) {
return { branch: projectDefault, source: "project-default" };
}
const legacyFallback = options.legacyFallbackBranch?.trim() || "main";
return { branch: legacyFallback, source: "legacy-main" };
}
const BLOCKING_TASK_STATUSES = new Set([
"failed",
// ── User-attention / awaiting-handoff states ─────────────────────────

View File

@@ -989,6 +989,8 @@ export interface MergeDetails {
mergedAt?: string;
mergeConfirmed?: boolean;
prNumber?: number;
mergeTargetBranch?: string;
mergeTargetSource?: "task-base-branch" | "task-branch-context" | "project-default" | "legacy-main";
resolutionStrategy?: "ai" | "auto-resolve" | "theirs" | "ours" | "abort";
resolutionMethod?: "ai" | "auto" | "mixed" | "theirs" | "ours" | "abort";
attemptsMade?: 1 | 2 | 3;

View File

@@ -33,6 +33,7 @@ import { hostname } from "node:os";
import {
getTaskMergeBlocker,
normalizeMergeConflictStrategy,
resolveTaskMergeTarget,
resolveProjectDefaultModel,
resolveTitleSummarizerSettingsModel,
resolveAgentPrompt,
@@ -4600,6 +4601,11 @@ export async function aiMergeTask(
// sites in mergeAttempt + attemptWithSideStrategy.
let mergeWasEmpty = false;
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
// 3. Check branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
@@ -4622,6 +4628,8 @@ export async function aiMergeTask(
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
prNumber: task.prInfo?.number,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
},
});
mergerLog.log(`${taskId}: branch missing; recovered owned landed commit ${ownedCommit.sha.slice(0, 8)}`);
@@ -4632,9 +4640,8 @@ export async function aiMergeTask(
return result;
}
// 3b. Ensure rootDir is on the main branch before merging.
// Without this, a merge could land on whatever branch was last checked out,
// causing feature code to be committed to the wrong lineage.
// 3b. Ensure rootDir is on the resolved merge target before merging.
// Without this, a merge could land on whatever branch was last checked out.
try {
throwIfAborted(options.signal, taskId);
const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
@@ -4642,32 +4649,16 @@ export async function aiMergeTask(
encoding: "utf-8",
stdio: "pipe",
}).trim();
const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
cwd: rootDir,
encoding: "utf-8",
stdio: "pipe",
}).trim().replace(/^origin\//, "");
if (currentBranch !== mainBranch) {
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mainBranch}' before merge`);
await execAsync(`git checkout "${mainBranch}"`, {
if (currentBranch !== mergeTarget.branch) {
mergerLog.log(`${taskId}: rootDir on '${currentBranch}', checking out '${mergeTarget.branch}' before merge (${mergeTarget.source})`);
await execAsync(`git checkout "${mergeTarget.branch}"`, {
cwd: rootDir,
});
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: mainBranch });
await audit.git({ type: "branch:checkout", target: mergeTarget.branch });
}
} catch (error: unknown) {
rethrowIfMergeAborted(error);
// Fallback: try checking out main directly
try {
throwIfAborted(options.signal, taskId);
await execAsync("git checkout main", { cwd: rootDir });
// Audit trail: record git checkout (FN-1404)
await audit.git({ type: "branch:checkout", target: "main" });
} catch (fallbackError: unknown) {
rethrowIfMergeAborted(fallbackError);
mergerLog.warn(`${taskId}: unable to verify/checkout main branch — proceeding on current HEAD`);
}
mergerLog.warn(`${taskId}: unable to verify/checkout merge target '${mergeTarget.branch}' — proceeding on current HEAD`);
}
// 3c. Pre-merge remote rebase.
@@ -5739,6 +5730,8 @@ export async function aiMergeTask(
mergeCommitMessage: aiMergeSummary || commitLog,
mergedAt: new Date().toISOString(),
mergeConfirmed: true,
mergeTargetBranch: mergeTarget.branch,
mergeTargetSource: mergeTarget.source,
resolutionStrategy: result.resolutionStrategy,
resolutionMethod: result.resolutionMethod,
attemptsMade: result.attemptsMade,
@@ -5756,7 +5749,7 @@ export async function aiMergeTask(
if (recordedSha) {
summaryParts.push(`commit ${recordedSha.slice(0, 8)}`);
} else if (mergeWasEmpty) {
summaryParts.push("no commit landed (branch already on main)");
summaryParts.push(`no commit landed (branch already on ${mergeTarget.branch})`);
} else if (isEmptyCommit) {
summaryParts.push("squash collapsed to empty (sha deferred)");
}