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:
5
.changeset/fn-3217-merge-target-branch.md
Normal file
5
.changeset/fn-3217-merge-target-branch.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ─────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user