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:
99
packages/core/src/__tests__/branch-assignment.test.ts
Normal file
99
packages/core/src/__tests__/branch-assignment.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
76
packages/core/src/branch-assignment.ts
Normal file
76
packages/core/src/branch-assignment.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user