takt: refactor-project-config-case (#358)
This commit is contained in:
parent
c066db46c7
commit
9e68f086d4
@ -34,6 +34,7 @@ import {
|
|||||||
updateWorktreeSession,
|
updateWorktreeSession,
|
||||||
getLanguage,
|
getLanguage,
|
||||||
loadProjectConfig,
|
loadProjectConfig,
|
||||||
|
saveProjectConfig,
|
||||||
isVerboseMode,
|
isVerboseMode,
|
||||||
resolveConfigValue,
|
resolveConfigValue,
|
||||||
invalidateGlobalConfigCache,
|
invalidateGlobalConfigCache,
|
||||||
@ -501,60 +502,6 @@ describe('analytics config resolution', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('model config resolution', () => {
|
|
||||||
let testDir: string;
|
|
||||||
let originalTaktConfigDir: string | undefined;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
||||||
mkdirSync(testDir, { recursive: true });
|
|
||||||
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
||||||
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
|
|
||||||
invalidateGlobalConfigCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (originalTaktConfigDir === undefined) {
|
|
||||||
delete process.env.TAKT_CONFIG_DIR;
|
|
||||||
} else {
|
|
||||||
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
||||||
}
|
|
||||||
invalidateGlobalConfigCache();
|
|
||||||
|
|
||||||
if (existsSync(testDir)) {
|
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve project model over global model', () => {
|
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nmodel: project-model\n');
|
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
|
||||||
writeFileSync(join(globalConfigDir, 'config.yaml'), 'model: global-model\n');
|
|
||||||
|
|
||||||
expect(resolveConfigValue(testDir, 'model')).toBe('project-model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fallback to global model when project model is not set', () => {
|
|
||||||
const projectConfigDir = getProjectConfigDir(testDir);
|
|
||||||
mkdirSync(projectConfigDir, { recursive: true });
|
|
||||||
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n');
|
|
||||||
|
|
||||||
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
||||||
mkdirSync(globalConfigDir, { recursive: true });
|
|
||||||
writeFileSync(join(globalConfigDir, 'config.yaml'), 'model: global-model\n');
|
|
||||||
|
|
||||||
expect(resolveConfigValue(testDir, 'model')).toBe('global-model');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return undefined when neither project nor global model is set', () => {
|
|
||||||
expect(resolveConfigValue(testDir, 'model')).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isVerboseMode', () => {
|
describe('isVerboseMode', () => {
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
let originalTaktConfigDir: string | undefined;
|
let originalTaktConfigDir: string | undefined;
|
||||||
@ -1209,3 +1156,157 @@ describe('provider-based session management', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadProjectConfig snake_case normalization', () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize auto_pr → autoPr and remove snake_case key', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n');
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.autoPr).toBe(true);
|
||||||
|
expect((config as Record<string, unknown>).auto_pr).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize draft_pr → draftPr and remove snake_case key', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n');
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.draftPr).toBe(true);
|
||||||
|
expect((config as Record<string, unknown>).draft_pr).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize base_branch → baseBranch and remove snake_case key', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n');
|
||||||
|
|
||||||
|
const config = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(config.baseBranch).toBe('main');
|
||||||
|
expect((config as Record<string, unknown>).base_branch).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveProjectConfig snake_case denormalization', () => {
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist autoPr as auto_pr and reload correctly', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', autoPr: true });
|
||||||
|
|
||||||
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(saved.autoPr).toBe(true);
|
||||||
|
expect((saved as Record<string, unknown>).auto_pr).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist draftPr as draft_pr and reload correctly', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', draftPr: true });
|
||||||
|
|
||||||
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(saved.draftPr).toBe(true);
|
||||||
|
expect((saved as Record<string, unknown>).draft_pr).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist baseBranch as base_branch and reload correctly', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' });
|
||||||
|
|
||||||
|
const saved = loadProjectConfig(testDir);
|
||||||
|
|
||||||
|
expect(saved.baseBranch).toBe('main');
|
||||||
|
expect((saved as Record<string, unknown>).base_branch).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not write camelCase keys to YAML file', () => {
|
||||||
|
saveProjectConfig(testDir, { piece: 'default', autoPr: true, draftPr: false, baseBranch: 'develop' });
|
||||||
|
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
||||||
|
|
||||||
|
expect(content).toContain('auto_pr:');
|
||||||
|
expect(content).toContain('draft_pr:');
|
||||||
|
expect(content).toContain('base_branch:');
|
||||||
|
expect(content).not.toContain('autoPr:');
|
||||||
|
expect(content).not.toContain('draftPr:');
|
||||||
|
expect(content).not.toContain('baseBranch:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveConfigValue autoPr/draftPr/baseBranch from project config', () => {
|
||||||
|
let testDir: string;
|
||||||
|
let originalTaktConfigDir: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
||||||
|
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalTaktConfigDir === undefined) {
|
||||||
|
delete process.env.TAKT_CONFIG_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
||||||
|
}
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
|
if (existsSync(testDir)) {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve autoPr from project config written in snake_case YAML', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n');
|
||||||
|
|
||||||
|
expect(resolveConfigValue(testDir, 'autoPr')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve draftPr from project config written in snake_case YAML', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n');
|
||||||
|
|
||||||
|
expect(resolveConfigValue(testDir, 'draftPr')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should resolve baseBranch from project config written in snake_case YAML', () => {
|
||||||
|
const projectConfigDir = getProjectConfigDir(testDir);
|
||||||
|
mkdirSync(projectConfigDir, { recursive: true });
|
||||||
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n');
|
||||||
|
|
||||||
|
expect(resolveConfigValue(testDir, 'baseBranch')).toBe('main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -104,11 +104,24 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
|
|
||||||
applyProjectConfigEnvOverrides(parsedConfig);
|
applyProjectConfigEnvOverrides(parsedConfig);
|
||||||
|
|
||||||
|
const {
|
||||||
|
auto_pr,
|
||||||
|
draft_pr,
|
||||||
|
base_branch,
|
||||||
|
provider_options,
|
||||||
|
provider_profiles,
|
||||||
|
analytics,
|
||||||
|
...rest
|
||||||
|
} = parsedConfig;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...DEFAULT_PROJECT_CONFIG,
|
...DEFAULT_PROJECT_CONFIG,
|
||||||
...(parsedConfig as ProjectLocalConfig),
|
...(rest as ProjectLocalConfig),
|
||||||
analytics: normalizeAnalytics(parsedConfig.analytics as Record<string, unknown> | undefined),
|
autoPr: auto_pr as boolean | undefined,
|
||||||
providerOptions: normalizeProviderOptions(parsedConfig.provider_options as {
|
draftPr: draft_pr as boolean | undefined,
|
||||||
|
baseBranch: base_branch as string | undefined,
|
||||||
|
analytics: normalizeAnalytics(analytics as Record<string, unknown> | undefined),
|
||||||
|
providerOptions: normalizeProviderOptions(provider_options as {
|
||||||
codex?: { network_access?: boolean };
|
codex?: { network_access?: boolean };
|
||||||
opencode?: { network_access?: boolean };
|
opencode?: { network_access?: boolean };
|
||||||
claude?: {
|
claude?: {
|
||||||
@ -118,7 +131,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
} | undefined),
|
} | undefined),
|
||||||
providerProfiles: normalizeProviderProfiles(parsedConfig.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,21 +142,21 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
const configDir = getConfigDir(projectDir);
|
const configDir = getConfigDir(projectDir);
|
||||||
const configPath = getConfigPath(projectDir);
|
const configPath = getConfigPath(projectDir);
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (!existsSync(configDir)) {
|
if (!existsSync(configDir)) {
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy project resources (only copies files that don't exist)
|
|
||||||
copyProjectResourcesToDir(configDir);
|
copyProjectResourcesToDir(configDir);
|
||||||
|
|
||||||
const savePayload: Record<string, unknown> = { ...config };
|
const savePayload: Record<string, unknown> = { ...config };
|
||||||
|
|
||||||
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
||||||
if (rawAnalytics) {
|
if (rawAnalytics) {
|
||||||
savePayload.analytics = rawAnalytics;
|
savePayload.analytics = rawAnalytics;
|
||||||
} else {
|
} else {
|
||||||
delete savePayload.analytics;
|
delete savePayload.analytics;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
||||||
if (rawProfiles && Object.keys(rawProfiles).length > 0) {
|
if (rawProfiles && Object.keys(rawProfiles).length > 0) {
|
||||||
savePayload.provider_profiles = rawProfiles;
|
savePayload.provider_profiles = rawProfiles;
|
||||||
@ -153,6 +166,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
|
|||||||
delete savePayload.providerProfiles;
|
delete savePayload.providerProfiles;
|
||||||
delete savePayload.providerOptions;
|
delete savePayload.providerOptions;
|
||||||
|
|
||||||
|
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
||||||
|
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
||||||
|
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
||||||
|
delete savePayload.autoPr;
|
||||||
|
delete savePayload.draftPr;
|
||||||
|
delete savePayload.baseBranch;
|
||||||
|
|
||||||
const content = stringify(savePayload, { indent: 2 });
|
const content = stringify(savePayload, { indent: 2 });
|
||||||
writeFileSync(configPath, content, 'utf-8');
|
writeFileSync(configPath, content, 'utf-8');
|
||||||
invalidateResolvedConfigCache(projectDir);
|
invalidateResolvedConfigCache(projectDir);
|
||||||
|
|||||||
@ -113,30 +113,7 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
|
|||||||
project: ReturnType<typeof loadProjectConfigCached>,
|
project: ReturnType<typeof loadProjectConfigCached>,
|
||||||
key: K,
|
key: K,
|
||||||
): LoadedConfig[K] | undefined {
|
): LoadedConfig[K] | undefined {
|
||||||
switch (key) {
|
return project[key as keyof typeof project] as LoadedConfig[K] | undefined;
|
||||||
case 'piece':
|
|
||||||
return project.piece as LoadedConfig[K] | undefined;
|
|
||||||
case 'provider':
|
|
||||||
return project.provider as LoadedConfig[K] | undefined;
|
|
||||||
case 'model':
|
|
||||||
return project.model as LoadedConfig[K] | undefined;
|
|
||||||
case 'autoPr':
|
|
||||||
return project.auto_pr as LoadedConfig[K] | undefined;
|
|
||||||
case 'draftPr':
|
|
||||||
return project.draft_pr as LoadedConfig[K] | undefined;
|
|
||||||
case 'verbose':
|
|
||||||
return project.verbose as LoadedConfig[K] | undefined;
|
|
||||||
case 'analytics':
|
|
||||||
return project.analytics as LoadedConfig[K] | undefined;
|
|
||||||
case 'providerOptions':
|
|
||||||
return project.providerOptions as LoadedConfig[K] | undefined;
|
|
||||||
case 'providerProfiles':
|
|
||||||
return project.providerProfiles as LoadedConfig[K] | undefined;
|
|
||||||
case 'baseBranch':
|
|
||||||
return project.base_branch as LoadedConfig[K] | undefined;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGlobalLayerValue<K extends ConfigParameterKey>(
|
function getGlobalLayerValue<K extends ConfigParameterKey>(
|
||||||
|
|||||||
@ -12,26 +12,20 @@ export interface ProjectLocalConfig {
|
|||||||
piece?: string;
|
piece?: string;
|
||||||
/** Provider selection for agent runtime */
|
/** Provider selection for agent runtime */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
|
||||||
/** Model selection for agent runtime */
|
|
||||||
model?: string;
|
|
||||||
/** Auto-create PR after worktree execution */
|
/** Auto-create PR after worktree execution */
|
||||||
auto_pr?: boolean;
|
autoPr?: boolean;
|
||||||
/** Create PR as draft */
|
/** Create PR as draft */
|
||||||
draft_pr?: boolean;
|
draftPr?: boolean;
|
||||||
|
/** Base branch to clone from (overrides global baseBranch) */
|
||||||
|
baseBranch?: string;
|
||||||
/** Verbose output mode */
|
/** Verbose output mode */
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
/** Project-level analytics overrides */
|
/** Project-level analytics overrides */
|
||||||
analytics?: AnalyticsConfig;
|
analytics?: AnalyticsConfig;
|
||||||
/** Provider-specific options (overrides global, overridden by piece/movement) */
|
/** Provider-specific options (overrides global, overridden by piece/movement) */
|
||||||
provider_options?: MovementProviderOptions;
|
|
||||||
/** Provider-specific options (camelCase alias) */
|
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Provider-specific permission profiles (project-level override) */
|
/** Provider-specific permission profiles (project-level override) */
|
||||||
provider_profiles?: ProviderPermissionProfiles;
|
|
||||||
/** Provider-specific permission profiles (camelCase alias) */
|
|
||||||
providerProfiles?: ProviderPermissionProfiles;
|
providerProfiles?: ProviderPermissionProfiles;
|
||||||
/** Custom settings */
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persona session data for persistence */
|
/** Persona session data for persistence */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user