FN-5826: centralize branch assignment derivation

Centralize working-branch vs merge-target derivation and reuse it across branch-selection flows.

- add a shared core helper to derive working branch, merge target, and default branch assignment
- export the new helper from @fusion/core and add focused unit coverage for branch-assignment behavior
- update dashboard branch-selection route to consume the shared helper instead of duplicating derivation logic
- extend dashboard branch-selection tests to verify helper-driven branch/merge-target precedence

Files changed:
 .../core/src/__tests__/branch-assignment.test.ts   | 99 ++++++++++++++++++++++
 packages/core/src/branch-assignment.ts             | 76 +++++++++++++++++
 packages/core/src/index.ts                         | 11 +++
 .../src/__tests__/branch-selection.test.ts         | 12 +++
 packages/dashboard/src/routes/branch-selection.ts  | 39 +++++----
 5 files changed, 222 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-5826

Fusion-Task-Lineage: ad7c5487-5266-4281-abd6-33784dd7e488
This commit is contained in:
gsxdsm
2026-06-01 01:39:59 -07:00
parent 327f0a9a4f
commit 9ba3a8e453
5 changed files with 222 additions and 15 deletions

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
deriveAutoTaskBranchName,
derivePerTaskBranchName,
resolveEntryPointBranchAssignment,
sanitizeBranchSegment,
} from "../branch-assignment.js";
describe("branch-assignment", () => {
it("sanitizes branch segments", () => {
expect(sanitizeBranchSegment(" FN-123 add parser!!! ")).toBe("fn-123-add-parser");
});
it("derives per-task branches", () => {
expect(derivePerTaskBranchName("feature/planning", "FN-123 add parser")).toBe("feature/planning/fn-123-add-parser");
expect(derivePerTaskBranchName(undefined, "FN-123")).toBeUndefined();
expect(derivePerTaskBranchName("feature/planning", " ")).toBe("feature/planning");
});
it("derives auto task branches", () => {
expect(deriveAutoTaskBranchName("FN-5671", "Branch Strategy Dropdown")).toBe("fusion/fn-5671-branch-strategy-dropdown");
expect(deriveAutoTaskBranchName("FN-5671", " ")).toBe("fusion/fn-5671");
});
it("resolves shared mode with per-task working branch and shared merge target", () => {
const resolvedBranch = "feature/planning";
const assignment = resolveEntryPointBranchAssignment({
assignmentMode: "shared",
resolvedBranch,
taskSegment: "FN-123 add parser",
});
expect(assignment).toEqual({
workingBranch: "feature/planning/fn-123-add-parser",
mergeTargetBranch: "feature/planning",
});
expect(assignment.workingBranch).not.toBe(resolvedBranch);
});
it("resolves shared mode with empty segment fallback", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "shared",
resolvedBranch: "feature/planning",
taskSegment: " ",
})).toEqual({
workingBranch: "feature/planning",
mergeTargetBranch: "feature/planning",
});
});
it("resolves shared mode with undefined resolved branch", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "shared",
resolvedBranch: undefined,
taskSegment: "FN-123",
})).toEqual({
workingBranch: undefined,
mergeTargetBranch: undefined,
});
});
it("resolves per-task-derived mode", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "per-task-derived",
resolvedBranch: "feature/planning",
taskSegment: "FN-123 add parser",
})).toEqual({
workingBranch: "feature/planning/fn-123-add-parser",
mergeTargetBranch: undefined,
});
});
it("resolves project-default mode", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "project-default",
resolvedBranch: "feature/planning",
taskSegment: "FN-123 add parser",
})).toEqual({
workingBranch: undefined,
mergeTargetBranch: undefined,
});
});
it("resolves existing and custom-new modes", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "existing",
resolvedBranch: "feature/existing",
})).toEqual({
workingBranch: "feature/existing",
mergeTargetBranch: undefined,
});
expect(resolveEntryPointBranchAssignment({
assignmentMode: "custom-new",
resolvedBranch: "feature/custom",
})).toEqual({
workingBranch: "feature/custom",
mergeTargetBranch: undefined,
});
});
});

View File

@@ -0,0 +1,76 @@
export type EntryPointAssignmentMode = "shared" | "per-task-derived" | "project-default" | "existing" | "custom-new";
export interface EntryPointBranchAssignmentInput {
assignmentMode: EntryPointAssignmentMode;
resolvedBranch?: string;
taskSegment?: string;
}
export interface EntryPointBranchAssignment {
workingBranch?: string;
mergeTargetBranch?: string;
}
export function sanitizeBranchSegment(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._/-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-/.]+|[-/.]+$/g, "")
.slice(0, 48);
}
function normalizeOptionalBranch(value: string | undefined): string | undefined {
if (value === undefined || value === null) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function derivePerTaskBranchName(sharedBranch: string | undefined, taskSegment: string): string | undefined {
const base = normalizeOptionalBranch(sharedBranch);
if (!base) return undefined;
const segment = sanitizeBranchSegment(taskSegment);
if (!segment) return base;
return `${base}/${segment}`;
}
export function deriveAutoTaskBranchName(taskId: string, shortName: string): string {
const base = `fusion/${taskId.toLowerCase()}`;
const segment = sanitizeBranchSegment(shortName ?? "");
return segment ? `${base}-${segment}` : base;
}
/**
* Resolves task branch assignment for entry points with distinct working and merge-target concerns.
* In shared mode, the shared branch is only a merge target; the working branch is always per-task-derived.
*/
export function resolveEntryPointBranchAssignment(
input: EntryPointBranchAssignmentInput,
): EntryPointBranchAssignment {
const { assignmentMode, resolvedBranch, taskSegment = "" } = input;
switch (assignmentMode) {
case "shared":
return {
workingBranch: derivePerTaskBranchName(resolvedBranch, taskSegment),
mergeTargetBranch: normalizeOptionalBranch(resolvedBranch),
};
case "per-task-derived":
return {
workingBranch: derivePerTaskBranchName(resolvedBranch, taskSegment),
mergeTargetBranch: undefined,
};
case "project-default":
return {
workingBranch: undefined,
mergeTargetBranch: undefined,
};
case "existing":
case "custom-new":
return {
workingBranch: normalizeOptionalBranch(resolvedBranch),
mergeTargetBranch: undefined,
};
}
}

View File

@@ -1,6 +1,17 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js";
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
export {
resolveEntryPointBranchAssignment,
sanitizeBranchSegment,
derivePerTaskBranchName,
deriveAutoTaskBranchName,
} from "./branch-assignment.js";
export type {
EntryPointAssignmentMode,
EntryPointBranchAssignmentInput,
EntryPointBranchAssignment,
} from "./branch-assignment.js";
export { customProviderRegistryKey } from "./custom-provider-key.js";
export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js";
export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js";

View File

@@ -5,6 +5,7 @@ import {
getBranchSelectionMode,
resolveBranchAssignmentContext,
resolveBranchSelection,
resolveEntryPointBranchAssignment,
} from "../routes/branch-selection.js";
describe("branch-selection", () => {
@@ -46,4 +47,15 @@ describe("branch-selection", () => {
expect(getBranchSelectionMode(undefined)).toBeUndefined();
expect(getBranchSelectionMode({ mode: "auto-new" })).toBe("auto-new");
});
it("re-exports entry-point branch assignment helper", () => {
expect(resolveEntryPointBranchAssignment({
assignmentMode: "shared",
resolvedBranch: "feature/planning",
taskSegment: "FN-123 add parser",
})).toEqual({
workingBranch: "feature/planning/fn-123-add-parser",
mergeTargetBranch: "feature/planning",
});
});
});

View File

@@ -1,5 +1,25 @@
import {
deriveAutoTaskBranchName,
derivePerTaskBranchName,
resolveEntryPointBranchAssignment,
sanitizeBranchSegment,
} from "@fusion/core";
import type {
EntryPointAssignmentMode,
EntryPointBranchAssignment,
EntryPointBranchAssignmentInput,
} from "@fusion/core";
import { badRequest } from "../api-error.js";
export {
resolveEntryPointBranchAssignment,
};
export type {
EntryPointAssignmentMode,
EntryPointBranchAssignment,
EntryPointBranchAssignmentInput,
};
export type BranchSelectionMode =
| "project-default"
| "auto-new"
@@ -113,26 +133,15 @@ export function resolveBranchAssignmentContext(input: unknown): ResolvedBranchAs
};
}
function sanitizeSegment(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._/-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-/.]+|[-/.]+$/g, "")
.slice(0, 48);
export function sanitizeSegment(input: string): string {
return sanitizeBranchSegment(input);
}
export function deriveAutoTaskBranch(taskId: string, shortName: string): string {
const base = `fusion/${taskId.toLowerCase()}`;
const segment = sanitizeSegment(shortName ?? "");
return segment ? `${base}-${segment}` : base;
return deriveAutoTaskBranchName(taskId, shortName);
}
export function derivePerTaskBranch(sharedBranch: string | undefined, taskSegment: string): string | undefined {
const base = normalizeOptionalBranch(sharedBranch, "sharedBranch");
if (!base) return undefined;
const segment = sanitizeSegment(taskSegment);
if (!segment) return base;
return `${base}/${segment}`;
return derivePerTaskBranchName(base, taskSegment);
}