Merge pull request #643 from AutoMaker-Org/feature/v0.14.0rc-1768981415660-tt2v

feat: add import / export features in json / yaml format
This commit is contained in:
Shirone
2026-01-21 23:06:10 +00:00
committed by GitHub
18 changed files with 2473 additions and 10 deletions

View File

@@ -40,7 +40,8 @@
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "8.18.3"
"ws": "8.18.3",
"yaml": "2.7.0"
},
"devDependencies": {
"@types/cookie": "0.6.0",

View File

@@ -16,6 +16,8 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
import { createDeleteHandler } from './routes/delete.js';
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
import { createGenerateTitleHandler } from './routes/generate-title.js';
import { createExportHandler } from './routes/export.js';
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
@@ -46,6 +48,13 @@ export function createFeaturesRoutes(
router.post('/agent-output', createAgentOutputHandler(featureLoader));
router.post('/raw-output', createRawOutputHandler(featureLoader));
router.post('/generate-title', createGenerateTitleHandler(settingsService));
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
router.post(
'/check-conflicts',
validatePathParams('projectPath'),
createConflictCheckHandler(featureLoader)
);
return router;
}

View File

@@ -0,0 +1,96 @@
/**
* POST /export endpoint - Export features to JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import {
getFeatureExportService,
type ExportFormat,
type BulkExportOptions,
} from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ExportRequest {
projectPath: string;
/** Feature IDs to export. If empty/undefined, exports all features */
featureIds?: string[];
/** Export format: 'json' or 'yaml' */
format?: ExportFormat;
/** Whether to include description history */
includeHistory?: boolean;
/** Whether to include plan spec */
includePlanSpec?: boolean;
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Pretty print output */
prettyPrint?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
featureIds,
format = 'json',
includeHistory = true,
includePlanSpec = true,
category,
status,
prettyPrint = true,
metadata,
} = req.body as ExportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// Validate format
if (format !== 'json' && format !== 'yaml') {
res.status(400).json({
success: false,
error: 'format must be "json" or "yaml"',
});
return;
}
const options: BulkExportOptions = {
format,
includeHistory,
includePlanSpec,
category,
status,
featureIds,
prettyPrint,
metadata,
};
const exportData = await exportService.exportFeatures(projectPath, options);
// Return the export data as a string in the response
res.json({
success: true,
data: exportData,
format,
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
});
} catch (error) {
logError(error, 'Export features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,210 @@
/**
* POST /import endpoint - Import features from JSON or YAML format
*/
import type { Request, Response } from 'express';
import type { FeatureLoader } from '../../../services/feature-loader.js';
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
import { getFeatureExportService } from '../../../services/feature-export-service.js';
import { getErrorMessage, logError } from '../common.js';
interface ImportRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
/** Whether to overwrite existing features with same ID */
overwrite?: boolean;
/** Whether to preserve branch info from imported features */
preserveBranchInfo?: boolean;
/** Optional category to assign to all imported features */
targetCategory?: string;
}
interface ConflictCheckRequest {
projectPath: string;
/** Raw JSON or YAML string containing feature data */
data: string;
}
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const {
projectPath,
data,
overwrite = false,
preserveBranchInfo = false,
targetCategory,
} = req.body as ImportRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Detect format and parse the data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
});
return;
}
// Determine if this is a single feature or bulk import
const isBulkImport =
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
let results: FeatureImportResult[];
if (isBulkImport) {
// Bulk import
results = await exportService.importFeatures(projectPath, data, {
overwrite,
preserveBranchInfo,
targetCategory,
});
} else {
// Single feature import - we know it's not a bulk export at this point
// It must be either a Feature or FeatureExport
const singleData = parsed as Feature | FeatureExport;
const result = await exportService.importFeature(projectPath, {
data: singleData,
overwrite,
preserveBranchInfo,
targetCategory,
});
results = [result];
}
const successCount = results.filter((r) => r.success).length;
const failureCount = results.filter((r) => !r.success).length;
const allSuccessful = failureCount === 0;
res.json({
success: allSuccessful,
importedCount: successCount,
failedCount: failureCount,
results,
});
} catch (error) {
logError(error, 'Import features failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Create handler for checking conflicts before import
*/
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, data } = req.body as ConflictCheckRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!data) {
res.status(400).json({ success: false, error: 'data is required' });
return;
}
// Parse the import data
const format = exportService.detectFormat(data);
if (!format) {
res.status(400).json({
success: false,
error: 'Invalid data format. Expected valid JSON or YAML.',
});
return;
}
const parsed = exportService.parseImportData(data);
if (!parsed) {
res.status(400).json({
success: false,
error: 'Failed to parse import data.',
});
return;
}
// Extract features from the data using type guards
let featuresToCheck: Array<{ id: string; title?: string }> = [];
if (exportService.isBulkExport(parsed)) {
// Bulk export format
featuresToCheck = parsed.features.map((f) => ({
id: f.feature.id,
title: f.feature.title,
}));
} else if (exportService.isFeatureExport(parsed)) {
// Single FeatureExport format
featuresToCheck = [
{
id: parsed.feature.id,
title: parsed.feature.title,
},
];
} else if (exportService.isRawFeature(parsed)) {
// Raw Feature format
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
}
// Check each feature for conflicts in parallel
const conflicts: ConflictInfo[] = await Promise.all(
featuresToCheck.map(async (feature) => {
const existing = await featureLoader.get(projectPath, feature.id);
return {
featureId: feature.id,
title: feature.title,
existingTitle: existing?.title,
hasConflict: !!existing,
};
})
);
const hasConflicts = conflicts.some((c) => c.hasConflict);
res.json({
success: true,
hasConflicts,
conflicts,
totalFeatures: featuresToCheck.length,
conflictCount: conflicts.filter((c) => c.hasConflict).length,
});
} catch (error) {
logError(error, 'Conflict check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,540 @@
/**
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
*
* Provides functionality to:
* - Export single features to JSON or YAML format
* - Export multiple features (bulk export)
* - Import features from JSON or YAML data
* - Validate import data for compatibility
*/
import { createLogger } from '@automaker/utils';
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
import { FeatureLoader } from './feature-loader.js';
const logger = createLogger('FeatureExportService');
/** Current export format version */
export const FEATURE_EXPORT_VERSION = '1.0.0';
/** Supported export formats */
export type ExportFormat = 'json' | 'yaml';
/** Options for exporting features */
export interface ExportOptions {
/** Format to export in (default: 'json') */
format?: ExportFormat;
/** Whether to include description history (default: true) */
includeHistory?: boolean;
/** Whether to include plan spec (default: true) */
includePlanSpec?: boolean;
/** Optional metadata to include */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
/** Who/what is performing the export */
exportedBy?: string;
/** Pretty print output (default: true) */
prettyPrint?: boolean;
}
/** Options for bulk export */
export interface BulkExportOptions extends ExportOptions {
/** Filter by category */
category?: string;
/** Filter by status */
status?: string;
/** Feature IDs to include (if not specified, exports all) */
featureIds?: string[];
}
/** Result of a bulk export */
export interface BulkExportResult {
/** Export format version */
version: string;
/** ISO date string when the export was created */
exportedAt: string;
/** Number of features exported */
count: number;
/** The exported features */
features: FeatureExport[];
/** Export metadata */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
/**
* FeatureExportService - Manages feature export and import operations
*/
export class FeatureExportService {
private featureLoader: FeatureLoader;
constructor(featureLoader?: FeatureLoader) {
this.featureLoader = featureLoader || new FeatureLoader();
}
/**
* Export a single feature to the specified format
*
* @param projectPath - Path to the project
* @param featureId - ID of the feature to export
* @param options - Export options
* @returns Promise resolving to the exported feature string
*/
async exportFeature(
projectPath: string,
featureId: string,
options: ExportOptions = {}
): Promise<string> {
const feature = await this.featureLoader.get(projectPath, featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
return this.exportFeatureData(feature, options);
}
/**
* Export feature data to the specified format (without fetching from disk)
*
* @param feature - The feature to export
* @param options - Export options
* @returns The exported feature string
*/
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
const {
format = 'json',
includeHistory = true,
includePlanSpec = true,
metadata,
exportedBy,
prettyPrint = true,
} = options;
// Prepare feature data, optionally excluding some fields
const featureData = this.prepareFeatureForExport(feature, {
includeHistory,
includePlanSpec,
});
const exportData: FeatureExport = {
version: FEATURE_EXPORT_VERSION,
feature: featureData,
exportedAt: new Date().toISOString(),
...(exportedBy ? { exportedBy } : {}),
...(metadata ? { metadata } : {}),
};
return this.serialize(exportData, format, prettyPrint);
}
/**
* Export multiple features to the specified format
*
* @param projectPath - Path to the project
* @param options - Bulk export options
* @returns Promise resolving to the exported features string
*/
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
const {
format = 'json',
category,
status,
featureIds,
includeHistory = true,
includePlanSpec = true,
metadata,
prettyPrint = true,
} = options;
// Get all features
let features = await this.featureLoader.getAll(projectPath);
// Apply filters
if (featureIds && featureIds.length > 0) {
const idSet = new Set(featureIds);
features = features.filter((f) => idSet.has(f.id));
}
if (category) {
features = features.filter((f) => f.category === category);
}
if (status) {
features = features.filter((f) => f.status === status);
}
// Generate timestamp once for consistent export time across all features
const exportedAt = new Date().toISOString();
// Prepare feature exports
const featureExports: FeatureExport[] = features.map((feature) => ({
version: FEATURE_EXPORT_VERSION,
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
exportedAt,
}));
const bulkExport: BulkExportResult = {
version: FEATURE_EXPORT_VERSION,
exportedAt,
count: featureExports.length,
features: featureExports,
...(metadata ? { metadata } : {}),
};
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
return this.serialize(bulkExport, format, prettyPrint);
}
/**
* Import a feature from JSON or YAML data
*
* @param projectPath - Path to the project
* @param importData - Import configuration
* @returns Promise resolving to the import result
*/
async importFeature(
projectPath: string,
importData: FeatureImport
): Promise<FeatureImportResult> {
const warnings: string[] = [];
const errors: string[] = [];
try {
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
const feature = this.extractFeatureFromImport(importData.data);
if (!feature) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid import data: could not extract feature'],
};
}
// Validate required fields
const validationErrors = this.validateFeature(feature);
if (validationErrors.length > 0) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: validationErrors,
};
}
// Determine the feature ID to use
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
// Check for existing feature
const existingFeature = await this.featureLoader.get(projectPath, featureId);
if (existingFeature && !importData.overwrite) {
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
};
}
// Prepare feature for import
const featureToImport: Feature = {
...feature,
id: featureId,
// Optionally override category
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
// Clear branch info if not preserving
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
};
// Clear runtime-specific fields that shouldn't be imported
delete featureToImport.titleGenerating;
delete featureToImport.error;
// Handle image paths - they won't be valid after import
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
);
featureToImport.imagePaths = [];
}
// Handle text file paths - they won't be valid after import
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
warnings.push(
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
);
featureToImport.textFilePaths = [];
}
// Create or update the feature
if (existingFeature) {
await this.featureLoader.update(projectPath, featureId, featureToImport);
logger.info(`Updated feature ${featureId} via import`);
} else {
await this.featureLoader.create(projectPath, featureToImport);
logger.info(`Created feature ${featureId} via import`);
}
return {
success: true,
featureId,
importedAt: new Date().toISOString(),
warnings: warnings.length > 0 ? warnings : undefined,
wasOverwritten: !!existingFeature,
};
} catch (error) {
logger.error('Failed to import feature:', error);
return {
success: false,
importedAt: new Date().toISOString(),
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
};
}
}
/**
* Import multiple features from JSON or YAML data
*
* @param projectPath - Path to the project
* @param data - Raw JSON or YAML string, or parsed data
* @param options - Import options applied to all features
* @returns Promise resolving to array of import results
*/
async importFeatures(
projectPath: string,
data: string | BulkExportResult,
options: Omit<FeatureImport, 'data'> = {}
): Promise<FeatureImportResult[]> {
let bulkData: BulkExportResult;
// Parse if string
if (typeof data === 'string') {
const parsed = this.parseImportData(data);
if (!parsed || !this.isBulkExport(parsed)) {
return [
{
success: false,
importedAt: new Date().toISOString(),
errors: ['Invalid bulk import data: expected BulkExportResult format'],
},
];
}
bulkData = parsed as BulkExportResult;
} else {
bulkData = data;
}
// Import each feature
const results: FeatureImportResult[] = [];
for (const featureExport of bulkData.features) {
const result = await this.importFeature(projectPath, {
data: featureExport,
...options,
});
results.push(result);
}
const successCount = results.filter((r) => r.success).length;
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
return results;
}
/**
* Parse import data from JSON or YAML string
*
* @param data - Raw JSON or YAML string
* @returns Parsed data or null if parsing fails
*/
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
const trimmed = data.trim();
// Try JSON first
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed);
} catch {
// Fall through to YAML
}
}
// Try YAML
try {
return yamlParse(trimmed);
} catch (error) {
logger.error('Failed to parse import data:', error);
return null;
}
}
/**
* Detect the format of import data
*
* @param data - Raw string data
* @returns Detected format or null if unknown
*/
detectFormat(data: string): ExportFormat | null {
const trimmed = data.trim();
// JSON detection
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
JSON.parse(trimmed);
return 'json';
} catch {
// Not valid JSON
}
}
// YAML detection (if it parses and wasn't JSON)
try {
yamlParse(trimmed);
return 'yaml';
} catch {
// Not valid YAML either
}
return null;
}
/**
* Prepare a feature for export by optionally removing fields
*/
private prepareFeatureForExport(
feature: Feature,
options: { includeHistory?: boolean; includePlanSpec?: boolean }
): Feature {
const { includeHistory = true, includePlanSpec = true } = options;
// Clone to avoid modifying original
const exported: Feature = { ...feature };
// Remove transient fields that shouldn't be exported
delete exported.titleGenerating;
delete exported.error;
// Optionally exclude history
if (!includeHistory) {
delete exported.descriptionHistory;
}
// Optionally exclude plan spec
if (!includePlanSpec) {
delete exported.planSpec;
}
return exported;
}
/**
* Extract a Feature from import data (handles both raw and wrapped formats)
*/
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
if (!data || typeof data !== 'object') {
return null;
}
// Check if it's a FeatureExport wrapper
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
const exportData = data as FeatureExport;
return exportData.feature;
}
// Assume it's a raw Feature
return data as Feature;
}
/**
* Check if parsed data is a bulk export
*/
isBulkExport(data: unknown): data is BulkExportResult {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
}
/**
* Check if parsed data is a single FeatureExport
*/
isFeatureExport(data: unknown): data is FeatureExport {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
return (
'version' in obj &&
'feature' in obj &&
'exportedAt' in obj &&
typeof obj.feature === 'object' &&
obj.feature !== null &&
'id' in (obj.feature as Record<string, unknown>)
);
}
/**
* Check if parsed data is a raw Feature
*/
isRawFeature(data: unknown): data is Feature {
if (!data || typeof data !== 'object') {
return false;
}
const obj = data as Record<string, unknown>;
// A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
return 'id' in obj && !('feature' in obj && 'version' in obj);
}
/**
* Validate a feature has required fields
*/
private validateFeature(feature: Feature): string[] {
const errors: string[] = [];
if (!feature.description && !feature.title) {
errors.push('Feature must have at least a title or description');
}
if (!feature.category) {
errors.push('Feature must have a category');
}
return errors;
}
/**
* Serialize export data to string (handles both single feature and bulk exports)
*/
private serialize<T extends FeatureExport | BulkExportResult>(
data: T,
format: ExportFormat,
prettyPrint: boolean
): string {
if (format === 'yaml') {
return yamlStringify(data, {
indent: 2,
lineWidth: 120,
});
}
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
}
}
// Singleton instance
let featureExportServiceInstance: FeatureExportService | null = null;
/**
* Get the singleton feature export service instance
*/
export function getFeatureExportService(): FeatureExportService {
if (!featureExportServiceInstance) {
featureExportServiceInstance = new FeatureExportService();
}
return featureExportServiceInstance;
}

View File

@@ -0,0 +1,623 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js';
import type { Feature, FeatureExport } from '@automaker/types';
import type { FeatureLoader } from '@/services/feature-loader.js';
describe('feature-export-service.ts', () => {
let exportService: FeatureExportService;
let mockFeatureLoader: {
get: ReturnType<typeof vi.fn>;
getAll: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
generateFeatureId: ReturnType<typeof vi.fn>;
};
const testProjectPath = '/test/project';
const sampleFeature: Feature = {
id: 'feature-123-abc',
title: 'Test Feature',
category: 'UI',
description: 'A test feature description',
status: 'pending',
priority: 1,
dependencies: ['feature-456'],
descriptionHistory: [
{
description: 'Initial description',
timestamp: '2024-01-01T00:00:00.000Z',
source: 'initial',
},
],
planSpec: {
status: 'generated',
content: 'Plan content',
version: 1,
reviewedByUser: false,
},
imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'],
textFilePaths: [
{
id: 'file-1',
path: '/tmp/doc.txt',
filename: 'doc.txt',
mimeType: 'text/plain',
content: 'Some content',
},
],
};
beforeEach(() => {
vi.clearAllMocks();
// Create mock FeatureLoader instance
mockFeatureLoader = {
get: vi.fn(),
getAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'),
};
// Inject mock via constructor
exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader);
});
describe('exportFeatureData', () => {
it('should export feature to JSON format', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.version).toBe(FEATURE_EXPORT_VERSION);
expect(parsed.feature.id).toBe(sampleFeature.id);
expect(parsed.feature.title).toBe(sampleFeature.title);
expect(parsed.exportedAt).toBeDefined();
});
it('should export feature to YAML format', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' });
expect(result).toContain('version:');
expect(result).toContain('feature:');
expect(result).toContain('Test Feature');
expect(result).toContain('exportedAt:');
});
it('should exclude description history when option is false', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
includeHistory: false,
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.descriptionHistory).toBeUndefined();
});
it('should include description history by default', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.descriptionHistory).toBeDefined();
expect(parsed.feature.descriptionHistory).toHaveLength(1);
});
it('should exclude plan spec when option is false', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
includePlanSpec: false,
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.planSpec).toBeUndefined();
});
it('should include plan spec by default', () => {
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.planSpec).toBeDefined();
});
it('should include metadata when provided', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
metadata: { projectName: 'TestProject', branch: 'main' },
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' });
});
it('should include exportedBy when provided', () => {
const result = exportService.exportFeatureData(sampleFeature, {
format: 'json',
exportedBy: 'test-user',
});
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.exportedBy).toBe('test-user');
});
it('should remove transient fields (titleGenerating, error)', () => {
const featureWithTransient: Feature = {
...sampleFeature,
titleGenerating: true,
error: 'Some error',
};
const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' });
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.titleGenerating).toBeUndefined();
expect(parsed.feature.error).toBeUndefined();
});
it('should support compact JSON (prettyPrint: false)', () => {
const prettyResult = exportService.exportFeatureData(sampleFeature, {
format: 'json',
prettyPrint: true,
});
const compactResult = exportService.exportFeatureData(sampleFeature, {
format: 'json',
prettyPrint: false,
});
// Compact should have no newlines/indentation
expect(compactResult).not.toContain('\n');
// Pretty should have newlines
expect(prettyResult).toContain('\n');
});
});
describe('exportFeature', () => {
it('should fetch and export feature by ID', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc');
expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc');
const parsed = JSON.parse(result) as FeatureExport;
expect(parsed.feature.id).toBe(sampleFeature.id);
});
it('should throw when feature not found', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow(
'Feature nonexistent not found'
);
});
});
describe('exportFeatures', () => {
const features: Feature[] = [
{ ...sampleFeature, id: 'feature-1', category: 'UI' },
{ ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' },
{ ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' },
];
it('should export all features', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath);
const parsed = JSON.parse(result);
expect(parsed.count).toBe(3);
expect(parsed.features).toHaveLength(3);
});
it('should filter by category', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' });
const parsed = JSON.parse(result);
expect(parsed.count).toBe(2);
expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true);
});
it('should filter by status', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' });
const parsed = JSON.parse(result);
expect(parsed.count).toBe(1);
expect(parsed.features[0].feature.status).toBe('completed');
});
it('should filter by feature IDs', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, {
featureIds: ['feature-1', 'feature-3'],
});
const parsed = JSON.parse(result);
expect(parsed.count).toBe(2);
const ids = parsed.features.map((f: FeatureExport) => f.feature.id);
expect(ids).toContain('feature-1');
expect(ids).toContain('feature-3');
expect(ids).not.toContain('feature-2');
});
it('should export to YAML format', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' });
expect(result).toContain('version:');
expect(result).toContain('count:');
expect(result).toContain('features:');
});
it('should include metadata when provided', async () => {
mockFeatureLoader.getAll.mockResolvedValue(features);
const result = await exportService.exportFeatures(testProjectPath, {
metadata: { projectName: 'TestProject' },
});
const parsed = JSON.parse(result);
expect(parsed.metadata).toEqual({ projectName: 'TestProject' });
});
});
describe('parseImportData', () => {
it('should parse valid JSON', () => {
const json = JSON.stringify(sampleFeature);
const result = exportService.parseImportData(json);
expect(result).toBeDefined();
expect((result as Feature).id).toBe(sampleFeature.id);
});
it('should parse valid YAML', () => {
const yaml = `
id: feature-yaml-123
title: YAML Feature
category: Testing
description: A YAML feature
`;
const result = exportService.parseImportData(yaml);
expect(result).toBeDefined();
expect((result as Feature).id).toBe('feature-yaml-123');
expect((result as Feature).title).toBe('YAML Feature');
});
it('should return null for invalid data', () => {
const result = exportService.parseImportData('not valid {json} or yaml: [');
expect(result).toBeNull();
});
it('should parse FeatureExport wrapper', () => {
const exportData: FeatureExport = {
version: '1.0.0',
feature: sampleFeature,
exportedAt: new Date().toISOString(),
};
const json = JSON.stringify(exportData);
const result = exportService.parseImportData(json) as FeatureExport;
expect(result.version).toBe('1.0.0');
expect(result.feature.id).toBe(sampleFeature.id);
});
});
describe('detectFormat', () => {
it('should detect JSON format', () => {
const json = JSON.stringify({ id: 'test' });
expect(exportService.detectFormat(json)).toBe('json');
});
it('should detect YAML format', () => {
const yaml = `
id: test
title: Test
`;
expect(exportService.detectFormat(yaml)).toBe('yaml');
});
it('should detect YAML for plain text (YAML is very permissive)', () => {
// YAML parses any plain text as a string, so this is detected as valid YAML
// The actual validation happens in parseImportData which checks for required fields
expect(exportService.detectFormat('not valid {[')).toBe('yaml');
});
it('should handle whitespace', () => {
const json = ' { "id": "test" } ';
expect(exportService.detectFormat(json)).toBe('json');
});
});
describe('importFeature', () => {
it('should import feature from raw Feature data', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe(sampleFeature.id);
expect(mockFeatureLoader.create).toHaveBeenCalled();
});
it('should import feature from FeatureExport wrapper', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const exportData: FeatureExport = {
version: '1.0.0',
feature: sampleFeature,
exportedAt: new Date().toISOString(),
};
const result = await exportService.importFeature(testProjectPath, {
data: exportData,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe(sampleFeature.id);
});
it('should use custom ID when provided', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
newId: 'custom-id-123',
});
expect(result.success).toBe(true);
expect(result.featureId).toBe('custom-id-123');
});
it('should fail when feature exists and overwrite is false', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
overwrite: false,
});
expect(result.success).toBe(false);
expect(result.errors).toContain(
`Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.`
);
});
it('should overwrite when overwrite is true', async () => {
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
mockFeatureLoader.update.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
overwrite: true,
});
expect(result.success).toBe(true);
expect(result.wasOverwritten).toBe(true);
expect(mockFeatureLoader.update).toHaveBeenCalled();
});
it('should apply target category override', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: sampleFeature,
targetCategory: 'NewCategory',
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].category).toBe('NewCategory');
});
it('should clear branch info when preserveBranchInfo is false', async () => {
const featureWithBranch: Feature = {
...sampleFeature,
branchName: 'feature/test-branch',
};
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithBranch,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: featureWithBranch,
preserveBranchInfo: false,
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].branchName).toBeUndefined();
});
it('should preserve branch info when preserveBranchInfo is true', async () => {
const featureWithBranch: Feature = {
...sampleFeature,
branchName: 'feature/test-branch',
};
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithBranch,
...data,
}));
await exportService.importFeature(testProjectPath, {
data: featureWithBranch,
preserveBranchInfo: true,
});
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].branchName).toBe('feature/test-branch');
});
it('should warn and clear image paths', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.warnings).toBeDefined();
expect(result.warnings).toContainEqual(expect.stringContaining('image path'));
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].imagePaths).toEqual([]);
});
it('should warn and clear text file paths', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
const result = await exportService.importFeature(testProjectPath, {
data: sampleFeature,
});
expect(result.warnings).toBeDefined();
expect(result.warnings).toContainEqual(expect.stringContaining('text file path'));
const createCall = mockFeatureLoader.create.mock.calls[0];
expect(createCall[1].textFilePaths).toEqual([]);
});
it('should fail with validation error for missing required fields', async () => {
const invalidFeature = {
id: 'feature-invalid',
// Missing description, title, and category
} as Feature;
const result = await exportService.importFeature(testProjectPath, {
data: invalidFeature,
});
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true);
});
it('should generate ID when none provided', async () => {
const featureWithoutId = {
title: 'No ID Feature',
category: 'Testing',
description: 'Feature without ID',
} as Feature;
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...featureWithoutId,
id: data.id!,
}));
const result = await exportService.importFeature(testProjectPath, {
data: featureWithoutId,
});
expect(result.success).toBe(true);
expect(result.featureId).toBe('feature-mock-id');
});
});
describe('importFeatures', () => {
const bulkExport = {
version: '1.0.0',
exportedAt: new Date().toISOString(),
count: 2,
features: [
{
version: '1.0.0',
feature: { ...sampleFeature, id: 'feature-1' },
exportedAt: new Date().toISOString(),
},
{
version: '1.0.0',
feature: { ...sampleFeature, id: 'feature-2' },
exportedAt: new Date().toISOString(),
},
],
};
it('should import multiple features from JSON string', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(
testProjectPath,
JSON.stringify(bulkExport)
);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(true);
});
it('should import multiple features from parsed data', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(testProjectPath, bulkExport);
expect(results).toHaveLength(2);
expect(results.every((r) => r.success)).toBe(true);
});
it('should apply options to all features', async () => {
mockFeatureLoader.get.mockResolvedValue(null);
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
...data,
}));
await exportService.importFeatures(testProjectPath, bulkExport, {
targetCategory: 'ImportedCategory',
});
const createCalls = mockFeatureLoader.create.mock.calls;
expect(createCalls[0][1].category).toBe('ImportedCategory');
expect(createCalls[1][1].category).toBe('ImportedCategory');
});
it('should return error for invalid bulk format', async () => {
const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }');
expect(results).toHaveLength(1);
expect(results[0].success).toBe(false);
expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data'));
});
it('should handle partial failures', async () => {
mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
...sampleFeature,
id: data.id!,
}));
const results = await exportService.importFeatures(testProjectPath, bulkExport, {
overwrite: false,
});
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false); // Exists without overwrite
});
});
});

View File

@@ -10,20 +10,22 @@ interface BoardControlsProps {
export function BoardControls({ isMounted, onShowBoardBackground }: BoardControlsProps) {
if (!isMounted) return null;
const buttonClass = cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
);
return (
<TooltipProvider>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
{/* Board Background Button */}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onShowBoardBackground}
className={cn(
'inline-flex h-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-all duration-200 cursor-pointer',
'text-muted-foreground hover:text-foreground hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'border border-border'
)}
className={buttonClass}
data-testid="board-background-button"
>
<ImageIcon className="w-4 h-4" />

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Download, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Feature } from '@/store/app-store';
type ExportFormat = 'json' | 'yaml';
interface ExportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
features: Feature[];
selectedFeatureIds?: string[];
}
export function ExportFeaturesDialog({
open,
onOpenChange,
projectPath,
features,
selectedFeatureIds,
}: ExportFeaturesDialogProps) {
const [format, setFormat] = useState<ExportFormat>('json');
const [includeHistory, setIncludeHistory] = useState(true);
const [includePlanSpec, setIncludePlanSpec] = useState(true);
const [isExporting, setIsExporting] = useState(false);
// Determine which features to export
const featuresToExport =
selectedFeatureIds && selectedFeatureIds.length > 0
? features.filter((f) => selectedFeatureIds.includes(f.id))
: features;
// Reset state when dialog opens
useEffect(() => {
if (open) {
setFormat('json');
setIncludeHistory(true);
setIncludePlanSpec(true);
}
}, [open]);
const handleExport = async () => {
setIsExporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.export(projectPath, {
featureIds: selectedFeatureIds,
format,
includeHistory,
includePlanSpec,
prettyPrint: true,
});
if (!result.success || !result.data) {
toast.error(result.error || 'Failed to export features');
return;
}
// Create a blob and trigger download
const mimeType = format === 'json' ? 'application/json' : 'application/x-yaml';
const blob = new Blob([result.data], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.filename || `features-export.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Exported ${featuresToExport.length} feature(s) to ${format.toUpperCase()}`);
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to export features');
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="export-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Download className="w-5 h-5" />
Export Features
</DialogTitle>
<DialogDescription>
Export {featuresToExport.length} feature(s) to a file for backup or sharing with other
projects.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
{/* Format Selection */}
<div className="space-y-2">
<Label>Export Format</Label>
<Select value={format} onValueChange={(v) => setFormat(v as ExportFormat)}>
<SelectTrigger data-testid="export-format-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
<div className="flex items-center gap-2">
<FileJson className="w-4 h-4" />
<span>JSON</span>
</div>
</SelectItem>
<SelectItem value="yaml">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<span>YAML</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Options */}
<div className="space-y-3">
<Label>Options</Label>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="include-history"
checked={includeHistory}
onCheckedChange={(checked) => setIncludeHistory(!!checked)}
data-testid="export-include-history"
/>
<Label htmlFor="include-history" className="text-sm font-normal cursor-pointer">
Include description history
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="include-plan-spec"
checked={includePlanSpec}
onCheckedChange={(checked) => setIncludePlanSpec(!!checked)}
data-testid="export-include-plan-spec"
/>
<Label htmlFor="include-plan-spec" className="text-sm font-normal cursor-pointer">
Include plan specifications
</Label>
</div>
</div>
</div>
{/* Features to Export Preview */}
{featuresToExport.length > 0 && featuresToExport.length <= 10 && (
<div className="space-y-2">
<Label className="text-muted-foreground">Features to export</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{featuresToExport.map((f) => (
<div key={f.id} className="py-1 px-2 truncate text-muted-foreground">
{f.title || f.description.slice(0, 50)}...
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isExporting}>
Cancel
</Button>
<Button onClick={handleExport} disabled={isExporting} data-testid="confirm-export">
<Download className="w-4 h-4 mr-2" />
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,474 @@
import { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { CategoryAutocomplete } from '@/components/ui/category-autocomplete';
import { Upload, AlertTriangle, CheckCircle2, XCircle, FileJson, FileText } from 'lucide-react';
import { toast } from 'sonner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { cn } from '@/lib/utils';
interface ConflictInfo {
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}
interface ImportResult {
success: boolean;
featureId?: string;
importedAt: string;
warnings?: string[];
errors?: string[];
wasOverwritten?: boolean;
}
interface ImportFeaturesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectPath: string;
categorySuggestions: string[];
onImportComplete?: () => void;
}
type ImportStep = 'upload' | 'review' | 'result';
export function ImportFeaturesDialog({
open,
onOpenChange,
projectPath,
categorySuggestions,
onImportComplete,
}: ImportFeaturesDialogProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<ImportStep>('upload');
const [fileData, setFileData] = useState<string>('');
const [fileName, setFileName] = useState<string>('');
const [fileFormat, setFileFormat] = useState<'json' | 'yaml' | null>(null);
// Options
const [overwrite, setOverwrite] = useState(false);
const [targetCategory, setTargetCategory] = useState('');
// Conflict check results
const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false);
// Import results
const [importResults, setImportResults] = useState<ImportResult[]>([]);
const [isImporting, setIsImporting] = useState(false);
// Parse error
const [parseError, setParseError] = useState<string>('');
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('upload');
setFileData('');
setFileName('');
setFileFormat(null);
setOverwrite(false);
setTargetCategory('');
setConflicts([]);
setImportResults([]);
setParseError('');
}
}, [open]);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Check file extension
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please select a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
// Check for conflicts
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const checkConflicts = async (data: string) => {
setIsCheckingConflicts(true);
try {
const api = getHttpApiClient();
const result = await api.features.checkConflicts(projectPath, data);
if (!result.success) {
setParseError(result.error || 'Failed to parse import file');
setConflicts([]);
return;
}
setConflicts(result.conflicts || []);
setStep('review');
} catch (error) {
setParseError(error instanceof Error ? error.message : 'Failed to check conflicts');
} finally {
setIsCheckingConflicts(false);
}
};
const handleImport = async () => {
setIsImporting(true);
try {
const api = getHttpApiClient();
const result = await api.features.import(projectPath, fileData, {
overwrite,
targetCategory: targetCategory || undefined,
});
if (!result.success && result.failedCount === result.results?.length) {
toast.error(result.error || 'Failed to import features');
return;
}
setImportResults(result.results || []);
setStep('result');
const successCount = result.importedCount || 0;
const failCount = result.failedCount || 0;
if (failCount === 0) {
toast.success(`Successfully imported ${successCount} feature(s)`);
} else if (successCount > 0) {
toast.warning(`Imported ${successCount} feature(s), ${failCount} failed`);
} else {
toast.error(`Failed to import features`);
}
onImportComplete?.();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to import features');
} finally {
setIsImporting(false);
}
};
const handleDrop = async (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files[0];
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext !== 'json' && ext !== 'yaml' && ext !== 'yml') {
setParseError('Please drop a JSON or YAML file');
return;
}
try {
const content = await file.text();
setFileData(content);
setFileName(file.name);
setFileFormat(ext === 'yml' ? 'yaml' : (ext as 'json' | 'yaml'));
setParseError('');
await checkConflicts(content);
} catch {
setParseError('Failed to read file');
}
};
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
};
const conflictingFeatures = conflicts.filter((c) => c.hasConflict);
const hasConflicts = conflictingFeatures.length > 0;
const renderUploadStep = () => (
<div className="py-4 space-y-4">
{/* Drop Zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
'hover:border-primary/50 hover:bg-muted/30',
parseError ? 'border-destructive/50' : 'border-border'
)}
onClick={() => fileInputRef.current?.click()}
data-testid="import-drop-zone"
>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
onChange={handleFileSelect}
className="hidden"
/>
<div className="flex flex-col items-center gap-3">
<Upload className="w-8 h-8 text-muted-foreground" />
<div className="text-sm">
<span className="text-primary font-medium">Click to upload</span>
<span className="text-muted-foreground"> or drag and drop</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileJson className="w-3.5 h-3.5" />
<span>JSON</span>
<span>or</span>
<FileText className="w-3.5 h-3.5" />
<span>YAML</span>
</div>
</div>
</div>
{parseError && (
<div className="flex items-center gap-2 text-sm text-destructive">
<XCircle className="w-4 h-4" />
{parseError}
</div>
)}
{isCheckingConflicts && (
<div className="text-sm text-muted-foreground text-center">Analyzing file...</div>
)}
</div>
);
const renderReviewStep = () => (
<div className="py-4 space-y-4">
{/* File Info */}
<div className="flex items-center gap-2 p-3 rounded-md border border-border/50 bg-muted/30">
{fileFormat === 'json' ? (
<FileJson className="w-5 h-5 text-muted-foreground" />
) : (
<FileText className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1 truncate">
<div className="text-sm font-medium">{fileName}</div>
<div className="text-xs text-muted-foreground">
{conflicts.length} feature(s) to import
</div>
</div>
</div>
{/* Conflict Warning */}
{hasConflicts && (
<div className="flex items-start gap-2 p-3 rounded-md border border-warning/50 bg-warning/10">
<AlertTriangle className="w-5 h-5 text-warning shrink-0 mt-0.5" />
<div className="space-y-1">
<div className="text-sm font-medium text-warning">
{conflictingFeatures.length} conflict(s) detected
</div>
<div className="text-xs text-muted-foreground">
The following features already exist in this project:
</div>
<ul className="text-xs text-muted-foreground list-disc list-inside max-h-24 overflow-y-auto">
{conflictingFeatures.map((c) => (
<li key={c.featureId} className="truncate">
{c.existingTitle || c.featureId}
</li>
))}
</ul>
</div>
</div>
)}
{/* Options */}
<div className="space-y-3">
<Label>Import Options</Label>
{hasConflicts && (
<div className="flex items-center gap-2">
<Checkbox
id="overwrite"
checked={overwrite}
onCheckedChange={(checked) => setOverwrite(!!checked)}
data-testid="import-overwrite"
/>
<Label htmlFor="overwrite" className="text-sm font-normal cursor-pointer">
Overwrite existing features with same ID
</Label>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">
Target Category (optional - override imported categories)
</Label>
<CategoryAutocomplete
value={targetCategory}
onChange={setTargetCategory}
suggestions={categorySuggestions}
placeholder="Keep original categories"
data-testid="import-target-category"
/>
</div>
</div>
{/* Features Preview */}
<div className="space-y-2">
<Label className="text-muted-foreground">Features to import</Label>
<div className="max-h-40 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm">
{conflicts.map((c) => (
<div
key={c.featureId}
className={cn(
'py-1 px-2 flex items-center gap-2',
c.hasConflict && !overwrite ? 'text-warning' : 'text-muted-foreground'
)}
>
{c.hasConflict ? (
overwrite ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0" />
)
) : (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
)}
<span className="truncate">{c.title || c.featureId}</span>
{c.hasConflict && !overwrite && (
<span className="text-xs text-warning">(will skip)</span>
)}
</div>
))}
</div>
</div>
</div>
);
const renderResultStep = () => {
const successResults = importResults.filter((r) => r.success);
const failedResults = importResults.filter((r) => !r.success);
return (
<div className="py-4 space-y-4">
{/* Summary */}
<div className="flex items-center gap-4 justify-center">
{successResults.length > 0 && (
<div className="flex items-center gap-2 text-primary">
<CheckCircle2 className="w-5 h-5" />
<span className="font-medium">{successResults.length} imported</span>
</div>
)}
{failedResults.length > 0 && (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="w-5 h-5" />
<span className="font-medium">{failedResults.length} failed</span>
</div>
)}
</div>
{/* Results List */}
<div className="max-h-60 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2 text-sm space-y-1">
{importResults.map((result, idx) => (
<div
key={idx}
className={cn(
'py-1.5 px-2 rounded',
result.success ? 'text-foreground' : 'text-destructive bg-destructive/10'
)}
>
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle2 className="w-3.5 h-3.5 text-primary shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 text-destructive shrink-0" />
)}
<span className="truncate">{result.featureId || `Feature ${idx + 1}`}</span>
{result.wasOverwritten && (
<span className="text-xs text-muted-foreground">(overwritten)</span>
)}
</div>
{result.warnings && result.warnings.length > 0 && (
<div className="mt-1 pl-5 text-xs text-warning">
{result.warnings.map((w, i) => (
<div key={i}>{w}</div>
))}
</div>
)}
{result.errors && result.errors.length > 0 && (
<div className="mt-1 pl-5 text-xs text-destructive">
{result.errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="import-features-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Import Features
</DialogTitle>
<DialogDescription>
{step === 'upload' && 'Import features from a JSON or YAML export file.'}
{step === 'review' && 'Review and configure import options.'}
{step === 'result' && 'Import completed.'}
</DialogDescription>
</DialogHeader>
{step === 'upload' && renderUploadStep()}
{step === 'review' && renderReviewStep()}
{step === 'result' && renderResultStep()}
<DialogFooter>
{step === 'upload' && (
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
)}
{step === 'review' && (
<>
<Button variant="ghost" onClick={() => setStep('upload')}>
Back
</Button>
<Button onClick={handleImport} disabled={isImporting} data-testid="confirm-import">
<Upload className="w-4 h-4 mr-2" />
{isImporting
? 'Importing...'
: `Import ${hasConflicts && !overwrite ? conflicts.filter((c) => !c.hasConflict).length : conflicts.length} Feature(s)`}
</Button>
</>
)}
{step === 'result' && (
<Button onClick={() => onOpenChange(false)} data-testid="close-import">
Done
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -13,3 +13,5 @@ export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';

View File

@@ -1,5 +1,13 @@
import type { LucideIcon } from 'lucide-react';
import { User, GitBranch, Palette, AlertTriangle, Workflow, FlaskConical } from 'lucide-react';
import {
User,
GitBranch,
Palette,
AlertTriangle,
Workflow,
Database,
FlaskConical,
} from 'lucide-react';
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
export interface ProjectNavigationItem {
@@ -14,5 +22,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
{ id: 'testing', label: 'Testing', icon: FlaskConical },
{ id: 'theme', label: 'Theme', icon: Palette },
{ id: 'claude', label: 'Models', icon: Workflow },
{ id: 'data', label: 'Data', icon: Database },
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
];

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Database, Download, Upload } from 'lucide-react';
import { ExportFeaturesDialog } from '../board-view/dialogs/export-features-dialog';
import { ImportFeaturesDialog } from '../board-view/dialogs/import-features-dialog';
import { useBoardFeatures } from '../board-view/hooks';
import type { Project } from '@/lib/electron';
interface DataManagementSectionProps {
project: Project;
}
export function DataManagementSection({ project }: DataManagementSectionProps) {
const [showExportDialog, setShowExportDialog] = useState(false);
const [showImportDialog, setShowImportDialog] = useState(false);
// Fetch features and persisted categories using the existing hook
const { features, persistedCategories, loadFeatures } = useBoardFeatures({
currentProject: project,
});
return (
<>
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center gap-3 mb-2">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
<Database className="w-5 h-5 text-brand-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">
Data Management
</h2>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
Export and import features to backup your data or share with other projects.
</p>
</div>
<div className="p-6 space-y-6">
{/* Export Section */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-foreground">Export Features</h3>
<p className="text-xs text-muted-foreground mt-1">
Download all features as a JSON or YAML file for backup or sharing.
</p>
</div>
<Button
variant="outline"
onClick={() => setShowExportDialog(true)}
className="gap-2"
data-testid="export-features-button"
>
<Download className="w-4 h-4" />
Export Features
</Button>
</div>
{/* Separator */}
<div className="border-t border-border/50" />
{/* Import Section */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium text-foreground">Import Features</h3>
<p className="text-xs text-muted-foreground mt-1">
Import features from a previously exported JSON or YAML file.
</p>
</div>
<Button
variant="outline"
onClick={() => setShowImportDialog(true)}
className="gap-2"
data-testid="import-features-button"
>
<Upload className="w-4 h-4" />
Import Features
</Button>
</div>
</div>
</div>
{/* Export Dialog */}
<ExportFeaturesDialog
open={showExportDialog}
onOpenChange={setShowExportDialog}
projectPath={project.path}
features={features}
/>
{/* Import Dialog */}
<ImportFeaturesDialog
open={showImportDialog}
onOpenChange={setShowImportDialog}
projectPath={project.path}
categorySuggestions={persistedCategories}
onImportComplete={() => {
loadFeatures();
}}
/>
</>
);
}

View File

@@ -6,6 +6,7 @@ export type ProjectSettingsViewId =
| 'worktrees'
| 'testing'
| 'claude'
| 'data'
| 'danger';
interface UseProjectSettingsViewOptions {

View File

@@ -7,6 +7,7 @@ import { ProjectThemeSection } from './project-theme-section';
import { WorktreePreferencesSection } from './worktree-preferences-section';
import { TestingSection } from './testing-section';
import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
@@ -90,6 +91,8 @@ export function ProjectSettingsView() {
return <TestingSection project={currentProject} />;
case 'claude':
return <ProjectModelsSection project={currentProject} />;
case 'data':
return <DataManagementSection project={currentProject} />;
case 'danger':
return (
<DangerZoneSection

View File

@@ -1688,6 +1688,64 @@ export class HttpApiClient implements ElectronAPI {
results?: Array<{ featureId: string; success: boolean; error?: string }>;
error?: string;
}>;
export: (
projectPath: string,
options?: {
featureIds?: string[];
format?: 'json' | 'yaml';
includeHistory?: boolean;
includePlanSpec?: boolean;
category?: string;
status?: string;
prettyPrint?: boolean;
metadata?: Record<string, unknown>;
}
) => Promise<{
success: boolean;
data?: string;
format?: 'json' | 'yaml';
contentType?: string;
filename?: string;
error?: string;
}>;
import: (
projectPath: string,
data: string,
options?: {
overwrite?: boolean;
preserveBranchInfo?: boolean;
targetCategory?: string;
}
) => Promise<{
success: boolean;
importedCount?: number;
failedCount?: number;
results?: Array<{
success: boolean;
featureId?: string;
importedAt: string;
warnings?: string[];
errors?: string[];
wasOverwritten?: boolean;
}>;
error?: string;
}>;
checkConflicts: (
projectPath: string,
data: string
) => Promise<{
success: boolean;
hasConflicts?: boolean;
conflicts?: Array<{
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}>;
totalFeatures?: number;
conflictCount?: number;
error?: string;
}>;
} = {
getAll: (projectPath: string) => this.post('/api/features/list', { projectPath }),
get: (projectPath: string, featureId: string) =>
@@ -1720,6 +1778,64 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/features/bulk-update', { projectPath, featureIds, updates }),
bulkDelete: (projectPath: string, featureIds: string[]) =>
this.post('/api/features/bulk-delete', { projectPath, featureIds }),
export: (
projectPath: string,
options?: {
featureIds?: string[];
format?: 'json' | 'yaml';
includeHistory?: boolean;
includePlanSpec?: boolean;
category?: string;
status?: string;
prettyPrint?: boolean;
metadata?: Record<string, unknown>;
}
): Promise<{
success: boolean;
data?: string;
format?: 'json' | 'yaml';
contentType?: string;
filename?: string;
error?: string;
}> => this.post('/api/features/export', { projectPath, ...options }),
import: (
projectPath: string,
data: string,
options?: {
overwrite?: boolean;
preserveBranchInfo?: boolean;
targetCategory?: string;
}
): Promise<{
success: boolean;
importedCount?: number;
failedCount?: number;
results?: Array<{
success: boolean;
featureId?: string;
importedAt: string;
warnings?: string[];
errors?: string[];
wasOverwritten?: boolean;
}>;
error?: string;
}> => this.post('/api/features/import', { projectPath, data, ...options }),
checkConflicts: (
projectPath: string,
data: string
): Promise<{
success: boolean;
hasConflicts?: boolean;
conflicts?: Array<{
featureId: string;
title?: string;
existingTitle?: string;
hasConflict: boolean;
}>;
totalFeatures?: number;
conflictCount?: number;
error?: string;
}> => this.post('/api/features/check-conflicts', { projectPath, data }),
};
// Auto Mode API

View File

@@ -72,3 +72,58 @@ export interface Feature {
}
export type FeatureStatus = 'pending' | 'running' | 'completed' | 'failed' | 'verified';
/**
* Export format for a feature, used when exporting features to share or backup
*/
export interface FeatureExport {
/** Export format version for compatibility checking */
version: string;
/** The feature data being exported */
feature: Feature;
/** ISO date string when the export was created */
exportedAt: string;
/** Optional identifier of who/what performed the export */
exportedBy?: string;
/** Additional metadata about the export context */
metadata?: {
projectName?: string;
projectPath?: string;
branch?: string;
[key: string]: unknown;
};
}
/**
* Options for importing a feature
*/
export interface FeatureImport {
/** The feature data to import (can be raw Feature or wrapped FeatureExport) */
data: Feature | FeatureExport;
/** Whether to overwrite an existing feature with the same ID */
overwrite?: boolean;
/** Whether to preserve the original branchName or ignore it */
preserveBranchInfo?: boolean;
/** Optional new ID to assign (if not provided, uses the feature's existing ID) */
newId?: string;
/** Optional new category to assign */
targetCategory?: string;
}
/**
* Result of a feature import operation
*/
export interface FeatureImportResult {
/** Whether the import was successful */
success: boolean;
/** The ID of the imported feature */
featureId?: string;
/** ISO date string when the import was completed */
importedAt: string;
/** Non-fatal warnings encountered during import */
warnings?: string[];
/** Errors that caused import failure */
errors?: string[];
/** Whether an existing feature was overwritten */
wasOverwritten?: boolean;
}

View File

@@ -64,6 +64,9 @@ export type {
FeatureTextFilePath,
FeatureStatus,
DescriptionHistoryEntry,
FeatureExport,
FeatureImport,
FeatureImportResult,
} from './feature.js';
// Session types

15
package-lock.json generated
View File

@@ -51,7 +51,8 @@
"express": "5.2.1",
"morgan": "1.10.1",
"node-pty": "1.1.0-beta41",
"ws": "8.18.3"
"ws": "8.18.3",
"yaml": "2.7.0"
},
"devDependencies": {
"@types/cookie": "0.6.0",
@@ -81,6 +82,18 @@
"undici-types": "~6.21.0"
}
},
"apps/server/node_modules/yaml": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"apps/ui": {
"name": "@automaker/ui",
"version": "0.13.0",