feat(core): one-time hard-move migration of workflow-policy settings to workflow setting values
30 moved keys (step execution, review/approval, per-phase model lanes) leave DEFAULT_PROJECT_SETTINGS; marker-gated idempotent per-project migration writes customized values to every in-use (workflowId, projectId); stale-writer guard; tombstone allowlist derived from the builtin catalog.
This commit is contained in:
@@ -70,12 +70,42 @@ interface MockTask {
|
||||
column: string;
|
||||
}
|
||||
|
||||
// `requirePrApproval` MOVED to workflow settings (U4): the CLI now resolves the
|
||||
// task's EFFECTIVE workflow settings and overlays them onto the project base. So a
|
||||
// mock store must expose `requirePrApproval` (and any moved key) through the
|
||||
// effective-settings resolver store surface (`getWorkflowSettingValues` etc.), not
|
||||
// through `getSettings()`. These stubs make `resolveEffectiveSettings` degrade to
|
||||
// `builtin:coding` and read the moved value from the stored workflow values.
|
||||
const MOVED_TEST_KEYS = new Set(["requirePrApproval"]);
|
||||
|
||||
function splitMovedSettings(settings: Record<string, unknown>) {
|
||||
const projectSettings: Record<string, unknown> = {};
|
||||
const workflowValues: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (MOVED_TEST_KEYS.has(key)) workflowValues[key] = value;
|
||||
else projectSettings[key] = value;
|
||||
}
|
||||
return { projectSettings, workflowValues };
|
||||
}
|
||||
|
||||
function workflowSettingsResolverStubs(workflowValues: Record<string, unknown>) {
|
||||
return {
|
||||
// No selection → resolver degrades to builtin:coding, whose declarations carry
|
||||
// the moved-key catalog; the stored values below override the declaration default.
|
||||
getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined),
|
||||
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
|
||||
getWorkflowSettingValues: vi.fn().mockReturnValue(workflowValues),
|
||||
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("test-project"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
|
||||
updates.push({ id, patch });
|
||||
}),
|
||||
@@ -86,6 +116,7 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
getBranchGroup: vi.fn().mockReturnValue(null),
|
||||
updateBranchGroup: vi.fn(),
|
||||
listTasksByBranchGroup: vi.fn().mockResolvedValue([]),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
_updates: updates,
|
||||
});
|
||||
}
|
||||
@@ -93,9 +124,11 @@ function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
|
||||
const emitter = new EventEmitter();
|
||||
let state = structuredClone(task);
|
||||
const { projectSettings, workflowValues } = splitMovedSettings(settings);
|
||||
return Object.assign(emitter, {
|
||||
getTask: vi.fn(async () => structuredClone(state)),
|
||||
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
|
||||
getSettings: vi.fn().mockResolvedValue({ ...projectSettings }),
|
||||
...workflowSettingsResolverStubs(workflowValues),
|
||||
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
state = { ...state, ...patch };
|
||||
}),
|
||||
|
||||
@@ -24,7 +24,7 @@ const execAsync = promisify(exec);
|
||||
const execFileAsync: (file: string, args: string[], opts?: import("node:child_process").ExecFileOptions) => Promise<{ stdout: string; stderr: string }> = (file, args, opts) =>
|
||||
(promisify(childProcess.execFile) as (f: string, a: string[], o?: object) => Promise<{ stdout: string; stderr: string }>)(file, args, opts);
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded } from "@fusion/core";
|
||||
import { resolveTaskMergeTarget, getCurrentRepo, isBranchGroupMemberLanded, resolveEffectiveSettings } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo, MergeResult, BranchGroup, BranchGroupPrState, Task } from "@fusion/core";
|
||||
import { activeSessionRegistry, resolveIntegrationBranch } from "@fusion/engine";
|
||||
import type { CreateGroupPrFn, SyncGroupPrFn, WorktreePool } from "@fusion/engine";
|
||||
@@ -448,6 +448,17 @@ export async function processPullRequestMergeTask(
|
||||
|
||||
const branch = getTaskBranchName(task.id);
|
||||
const settings = await store.getSettings();
|
||||
// `requirePrApproval` MOVED to workflow settings (U4): resolve the task's
|
||||
// effective workflow settings and overlay them onto the project/global base so
|
||||
// the approval-gate reads the per-(workflow, project) value post-migration. The
|
||||
// resolver never throws — a missing workflow degrades to built-in declaration
|
||||
// defaults (requirePrApproval=false), matching the pre-move default.
|
||||
try {
|
||||
const effective = await resolveEffectiveSettings(store, { id: task.id });
|
||||
Object.assign(settings as Record<string, unknown>, effective);
|
||||
} catch {
|
||||
// Defensive: keep the base settings if effective resolution fails entirely.
|
||||
}
|
||||
const resolvedIntegrationBranch = await resolveIntegrationBranch(cwd, settings);
|
||||
const projectDefaultBranch = resolvedIntegrationBranch;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user