Files
fusion/packages/dashboard/src/routes/branch-selection.ts
Fusion 5ee049b834 feat(FN-3214): add branch selection contract to planning routes and subtask
Adds branch selection and assignment to the planning workflow: defines a branch selection contract, wires it through subtask creation in SubtaskBreakdownModal, and applies the contract in planning route handlers, with corresponding test coverage. A minor typing fix in the bundled plugin views loader

Fusion-Task-Id: FN-3214
2026-05-09 22:11:09 -07:00

128 lines
3.6 KiB
TypeScript

import { badRequest } from "../api-error.js";
export type BranchSelectionMode =
| "project-default"
| "auto-new"
| "existing"
| "custom-new";
export interface BranchSelectionPayload {
mode?: unknown;
branchName?: unknown;
baseBranch?: unknown;
}
export type PlanningBranchMode = "shared" | "per-task-derived";
export interface ResolvedBranchSelection {
branch?: string;
baseBranch?: string;
}
export interface BranchAssignmentContext {
mode?: unknown;
}
export interface ResolvedBranchAssignmentContext {
mode: PlanningBranchMode;
}
function normalizeOptionalBranch(value: unknown, fieldName: string): string | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") {
throw badRequest(`${fieldName} must be a string`);
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
export function resolveBranchSelection(
selectionInput: unknown,
fallbackBranch: unknown,
fallbackBaseBranch: unknown,
): ResolvedBranchSelection {
const fallback = {
branch: normalizeOptionalBranch(fallbackBranch, "branch"),
baseBranch: normalizeOptionalBranch(fallbackBaseBranch, "baseBranch"),
};
if (selectionInput === undefined || selectionInput === null) {
return fallback;
}
if (typeof selectionInput !== "object" || Array.isArray(selectionInput)) {
throw badRequest("branchSelection must be an object");
}
const selection = selectionInput as BranchSelectionPayload;
const mode = typeof selection.mode === "string" ? selection.mode : undefined;
if (!mode) {
throw badRequest("branchSelection.mode is required");
}
if (![
"project-default",
"auto-new",
"existing",
"custom-new",
].includes(mode)) {
throw badRequest("branchSelection.mode must be one of: project-default, auto-new, existing, custom-new");
}
const baseBranch = normalizeOptionalBranch(selection.baseBranch, "branchSelection.baseBranch");
if (mode === "project-default") {
return { branch: undefined, baseBranch };
}
if (mode === "auto-new") {
// Auto-named branch is derived later by existing task-id based flow.
return { branch: undefined, baseBranch };
}
const branchName = normalizeOptionalBranch(selection.branchName, "branchSelection.branchName");
if (!branchName) {
throw badRequest("branchSelection.branchName is required for existing/custom-new modes");
}
return {
branch: branchName,
baseBranch,
};
}
export function resolveBranchAssignmentContext(input: unknown): ResolvedBranchAssignmentContext {
if (input === undefined || input === null) {
return { mode: "shared" };
}
if (typeof input !== "object" || Array.isArray(input)) {
throw badRequest("branchAssignment must be an object");
}
const payload = input as BranchAssignmentContext;
const mode = payload.mode;
if (mode !== undefined && mode !== "shared" && mode !== "per-task-derived") {
throw badRequest("branchAssignment.mode must be one of: shared, per-task-derived");
}
return {
mode: mode === "per-task-derived" ? "per-task-derived" : "shared",
};
}
function sanitizeSegment(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9._/-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-/.]+|[-/.]+$/g, "")
.slice(0, 48);
}
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}`;
}