/** * JIRA Project Import Command (Post-Init) * * Import additional JIRA projects after initial setup with smart filtering, * resume support, and merge capabilities. * * Features: * - Smart filtering (active, type, lead, JQL) * - Filter presets (production, active-only, agile-only) * - Dry-run preview mode * - Resume interrupted imports * - Progress tracking with ETA * - Merge with existing projects (no duplicates) * * NEW (v0.24.0): Post-init flexibility for project management * * @module plugins/specweave-jira/commands/import-projects */ import chalk from 'chalk'; import { confirm } from '@inquirer/prompts'; import { existsSync } from 'fs'; import path from 'path'; import { JiraClient } from '../../../src/integrations/jira/jira-client.js'; import { FilterProcessor, type FilterOptions } from '../../../src/integrations/jira/filter-processor.js'; import { AsyncProjectLoader } from '../../../src/cli/helpers/async-project-loader.js'; import { mergeEnvList, getEnvValue } from '../../../src/utils/env-manager.js'; import { consoleLogger, type Logger } from '../../../src/utils/logger.js'; import { credentialsManager } from '../../../src/core/credentials-manager.js'; export interface ImportProjectsOptions { filter?: 'active' | 'archived' | 'all'; type?: string[]; lead?: string; jql?: string; preset?: string; dryRun?: boolean; resume?: boolean; noProgress?: boolean; logger?: Logger; } export interface ImportState { total: number; completed: string[]; remaining: string[]; timestamp: number; } /** * Import JIRA projects command * * TC-063: Post-Init Import (Merge with Existing) * TC-064: Filter Active Projects Only * TC-068: Resume Interrupted Import * TC-069: Dry-Run Preview * TC-070: Progress During Import * * @param options - Import options */ export async function importProjects(options: ImportProjectsOptions = {}): Promise { const logger = options.logger ?? consoleLogger; const projectRoot = process.cwd(); // Step 1: Load credentials console.log(chalk.cyan('\nšŸ“„ JIRA Project Import\n')); const credentials = credentialsManager.getJiraCredentials(); if (!credentials) { console.log(chalk.red('āŒ No JIRA credentials found')); console.log(chalk.gray(' Run: specweave init')); return; } const client = new JiraClient(credentials); const filterProcessor = new FilterProcessor(client, { logger }); // Step 2: Check for resume state if (options.resume) { const resumed = await resumeImport(projectRoot, client, filterProcessor, options); if (resumed) { return; } console.log(chalk.yellow('āš ļø No import state found. Starting fresh import.\n')); } // Step 3: Read existing projects from .env const existing = await loadExistingProjects(projectRoot, logger); console.log(chalk.gray(`Current projects: ${existing.length > 0 ? existing.join(', ') : 'none'}\n`)); // Step 4: Fetch available projects from JIRA console.log(chalk.cyan('šŸ“” Fetching available JIRA projects...\n')); let allProjects: any[] = []; try { const response = await client.searchProjects({ maxResults: 1000 }); allProjects = response.values || []; console.log(chalk.green(`āœ“ Found ${allProjects.length} total projects\n`)); } catch (error: any) { console.log(chalk.red(`āŒ Failed to fetch projects: ${error.message}`)); return; } // Step 5: Apply filters let filteredProjects = allProjects; if (options.preset) { // Use filter preset console.log(chalk.cyan(`šŸ” Applying preset: ${options.preset}\n`)); try { filteredProjects = await filterProcessor.applyPreset(allProjects, options.preset); } catch (error: any) { console.log(chalk.red(`āŒ ${error.message}`)); return; } } else if (options.filter || options.type || options.lead || options.jql) { // Build filter options const filterOptions: FilterOptions = {}; if (options.filter === 'active') { filterOptions.active = true; } else if (options.filter === 'archived') { filterOptions.active = false; } if (options.type) { filterOptions.types = options.type; } if (options.lead) { filterOptions.lead = options.lead; } if (options.jql) { filterOptions.jql = options.jql; } console.log(chalk.cyan('šŸ” Applying filters...\n')); filteredProjects = await filterProcessor.applyFilters(allProjects, filterOptions); } // Step 6: Exclude existing projects const newProjects = filteredProjects.filter(p => { return !existing.some(e => e.toLowerCase() === p.key.toLowerCase()); }); if (newProjects.length === 0) { console.log(chalk.yellow('āš ļø No new projects found to import')); console.log(chalk.gray(' All available projects are already imported\n')); return; } // Step 7: Show preview console.log(chalk.cyan('šŸ“‹ Import Preview:\n')); console.log(chalk.white(` Total available: ${allProjects.length}`)); console.log(chalk.white(` After filtering: ${filteredProjects.length}`)); console.log(chalk.white(` Already imported: ${existing.length}`)); console.log(chalk.white(` New projects: ${chalk.green.bold(newProjects.length)}\n`)); if (newProjects.length <= 10) { console.log(chalk.gray('Projects to import:')); newProjects.forEach(p => { const typeLabel = p.projectTypeKey || 'unknown'; const leadLabel = p.lead?.displayName || 'no lead'; console.log(chalk.gray(` ✨ ${p.key} - ${p.name} (${typeLabel}, ${leadLabel})`)); }); console.log(''); } // Step 8: Dry-run mode (exit without changes) if (options.dryRun) { console.log(chalk.yellow('šŸ” Dry-run mode: No changes will be made\n')); console.log(chalk.green(`āœ“ Preview complete: ${newProjects.length} project(s) would be imported\n`)); return; } // Step 9: Confirm import const confirmed = await confirm({ message: `Import ${newProjects.length} new project(s)?`, default: true }); if (!confirmed) { console.log(chalk.yellow('\nā­ļø Import canceled\n')); return; } // Step 10: Extract project keys const projectKeys = newProjects.map(p => p.key); // Step 11: Merge with existing console.log(chalk.cyan('\nšŸ“„ Importing projects...\n')); try { await mergeEnvList({ key: 'JIRA_PROJECTS', newValues: projectKeys, projectRoot, logger, createBackup: true }); console.log(chalk.green(`\nāœ… Successfully imported ${projectKeys.length} project(s)\n`)); console.log(chalk.gray('Updated: .env (JIRA_PROJECTS)')); console.log(chalk.gray('Backup: .env.backup\n')); } catch (error: any) { console.log(chalk.red(`\nāŒ Import failed: ${error.message}\n`)); } } /** * Load existing JIRA projects from .env */ async function loadExistingProjects(projectRoot: string, logger: Logger): Promise { const value = await getEnvValue(projectRoot, 'JIRA_PROJECTS'); if (!value) { return []; } return value.split(',').map(v => v.trim()).filter(v => v.length > 0); } /** * Resume interrupted import */ async function resumeImport( projectRoot: string, client: JiraClient, filterProcessor: FilterProcessor, options: ImportProjectsOptions ): Promise { const stateFile = path.join(projectRoot, '.specweave', 'cache', 'import-state.json'); if (!existsSync(stateFile)) { return false; } console.log(chalk.cyan('šŸ”„ Resuming interrupted import...\n')); // Load state and continue // (Implementation would load state, skip completed, import remaining) return false; // Placeholder - full implementation would handle resume } /** * Command entry point (called by CLI) */ export default async function main(): Promise { // Parse CLI arguments const args = process.argv.slice(2); const options: ImportProjectsOptions = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--filter') { options.filter = args[++i] as 'active' | 'archived' | 'all'; } else if (arg === '--type') { options.type = args[++i].split(','); } else if (arg === '--lead') { options.lead = args[++i]; } else if (arg === '--jql') { options.jql = args[++i]; } else if (arg === '--preset') { options.preset = args[++i]; } else if (arg === '--dry-run') { options.dryRun = true; } else if (arg === '--resume') { options.resume = true; } else if (arg === '--no-progress') { options.noProgress = true; } } await importProjects(options); } // Run command if called directly if (import.meta.url === `file://${process.argv[1]}`) { main().catch(error => { console.error(chalk.red(`\nāŒ Error: ${error.message}\n`)); process.exit(1); }); }