| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'); |
|
|
| |
| export const FEATURE_EXPORT_VERSION = '1.0.0'; |
|
|
| |
| export type ExportFormat = 'json' | 'yaml'; |
|
|
| |
| export interface ExportOptions { |
| |
| format?: ExportFormat; |
| |
| includeHistory?: boolean; |
| |
| includePlanSpec?: boolean; |
| |
| metadata?: { |
| projectName?: string; |
| projectPath?: string; |
| branch?: string; |
| [key: string]: unknown; |
| }; |
| |
| exportedBy?: string; |
| |
| prettyPrint?: boolean; |
| } |
|
|
| |
| export interface BulkExportOptions extends ExportOptions { |
| |
| category?: string; |
| |
| status?: string; |
| |
| featureIds?: string[]; |
| } |
|
|
| |
| export interface BulkExportResult { |
| |
| version: string; |
| |
| exportedAt: string; |
| |
| count: number; |
| |
| features: FeatureExport[]; |
| |
| metadata?: { |
| projectName?: string; |
| projectPath?: string; |
| branch?: string; |
| [key: string]: unknown; |
| }; |
| } |
|
|
| |
| |
| |
| export class FeatureExportService { |
| private featureLoader: FeatureLoader; |
|
|
| constructor(featureLoader?: FeatureLoader) { |
| this.featureLoader = featureLoader || new FeatureLoader(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| exportFeatureData(feature: Feature, options: ExportOptions = {}): string { |
| const { |
| format = 'json', |
| includeHistory = true, |
| includePlanSpec = true, |
| metadata, |
| exportedBy, |
| prettyPrint = true, |
| } = options; |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> { |
| const { |
| format = 'json', |
| category, |
| status, |
| featureIds, |
| includeHistory = true, |
| includePlanSpec = true, |
| metadata, |
| prettyPrint = true, |
| } = options; |
|
|
| |
| let features = await this.featureLoader.getAll(projectPath); |
|
|
| |
| 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); |
| } |
|
|
| |
| const exportedAt = new Date().toISOString(); |
|
|
| |
| 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); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async importFeature( |
| projectPath: string, |
| importData: FeatureImport |
| ): Promise<FeatureImportResult> { |
| const warnings: string[] = []; |
|
|
| try { |
| |
| const feature = this.extractFeatureFromImport(importData.data); |
| if (!feature) { |
| return { |
| success: false, |
| importedAt: new Date().toISOString(), |
| errors: ['Invalid import data: could not extract feature'], |
| }; |
| } |
|
|
| |
| const validationErrors = this.validateFeature(feature); |
| if (validationErrors.length > 0) { |
| return { |
| success: false, |
| importedAt: new Date().toISOString(), |
| errors: validationErrors, |
| }; |
| } |
|
|
| |
| const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId(); |
|
|
| |
| 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.`], |
| }; |
| } |
|
|
| |
| const featureToImport: Feature = { |
| ...feature, |
| id: featureId, |
| |
| ...(importData.targetCategory ? { category: importData.targetCategory } : {}), |
| |
| ...(importData.preserveBranchInfo ? {} : { branchName: undefined }), |
| }; |
|
|
| |
| delete featureToImport.titleGenerating; |
| delete featureToImport.error; |
|
|
| |
| 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 = []; |
| } |
|
|
| |
| 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 = []; |
| } |
|
|
| |
| 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)}`], |
| }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async importFeatures( |
| projectPath: string, |
| data: string | BulkExportResult, |
| options: Omit<FeatureImport, 'data'> = {} |
| ): Promise<FeatureImportResult[]> { |
| let bulkData: BulkExportResult; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null { |
| const trimmed = data.trim(); |
|
|
| |
| if (trimmed.startsWith('{') || trimmed.startsWith('[')) { |
| try { |
| return JSON.parse(trimmed); |
| } catch { |
| |
| } |
| } |
|
|
| |
| try { |
| return yamlParse(trimmed); |
| } catch (error) { |
| logger.error('Failed to parse import data:', error); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| detectFormat(data: string): ExportFormat | null { |
| const trimmed = data.trim(); |
|
|
| |
| if (trimmed.startsWith('{') || trimmed.startsWith('[')) { |
| try { |
| JSON.parse(trimmed); |
| return 'json'; |
| } catch { |
| |
| } |
| } |
|
|
| |
| try { |
| yamlParse(trimmed); |
| return 'yaml'; |
| } catch { |
| |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| private prepareFeatureForExport( |
| feature: Feature, |
| options: { includeHistory?: boolean; includePlanSpec?: boolean } |
| ): Feature { |
| const { includeHistory = true, includePlanSpec = true } = options; |
|
|
| |
| const exported: Feature = { ...feature }; |
|
|
| |
| delete exported.titleGenerating; |
| delete exported.error; |
|
|
| |
| if (!includeHistory) { |
| delete exported.descriptionHistory; |
| } |
|
|
| |
| if (!includePlanSpec) { |
| delete exported.planSpec; |
| } |
|
|
| return exported; |
| } |
|
|
| |
| |
| |
| private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null { |
| if (!data || typeof data !== 'object') { |
| return null; |
| } |
|
|
| |
| if ('version' in data && 'feature' in data && 'exportedAt' in data) { |
| const exportData = data as FeatureExport; |
| return exportData.feature; |
| } |
|
|
| |
| return data as Feature; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
|
|
| |
| |
| |
| 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>) |
| ); |
| } |
|
|
| |
| |
| |
| isRawFeature(data: unknown): data is Feature { |
| if (!data || typeof data !== 'object') { |
| return false; |
| } |
| const obj = data as Record<string, unknown>; |
| |
| return 'id' in obj && !('feature' in obj && 'version' in obj); |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| let featureExportServiceInstance: FeatureExportService | null = null; |
|
|
| |
| |
| |
| export function getFeatureExportService(): FeatureExportService { |
| if (!featureExportServiceInstance) { |
| featureExportServiceInstance = new FeatureExportService(); |
| } |
| return featureExportServiceInstance; |
| } |
|
|