UNPKG

@fromsvenwithlove/devops-issues-cli

Version:

AI-powered CLI tool and library for Azure DevOps work item management with Claude agents

506 lines (436 loc) • 15.4 kB
import fs from 'fs/promises'; import path from 'path'; import ora from 'ora'; import chalk from 'chalk'; import { AzureDevOpsClient } from '../api/azure-client.js'; import { HierarchyProcessor } from '../utils/hierarchy-processor.js'; import { getConfig } from '../config/index.js'; /** * Create work items sequentially, ensuring parents are created before children * @param {AzureDevOpsClient} client - Azure DevOps client * @param {HierarchyProcessor} processor - Initialized hierarchy processor * @param {Array} sortedItems - Work items sorted by level * @param {number} globalParentId - Optional global parent ID * @param {Function} progressCallback - Progress callback function * @returns {Object} Creation results */ async function createWorkItemsSequentially(client, processor, sortedItems, globalParentId, progressCallback) { const totalItems = sortedItems.length; const created = []; const errors = []; const idMapping = new Map(); let completed = 0; // Track current level for progress messages let currentLevel = -1; for (const item of sortedItems) { try { // Update progress message when moving to new level if (item.level !== currentLevel) { currentLevel = item.level; const levelTypes = sortedItems .filter(i => i.level === currentLevel) .map(i => i.type) .filter((v, i, a) => a.indexOf(v) === i) .join(', '); if (progressCallback) { progressCallback({ current: completed, total: totalItems, message: `Creating level ${currentLevel} items (${levelTypes})`, item: null }); } } // Determine parent ID let parentId = null; if (item.parentTempId) { parentId = idMapping.get(item.parentTempId); if (!parentId) { throw new Error(`Parent ${item.parentTempId} not found in mapping`); } } else if (globalParentId) { parentId = globalParentId; } // Extract fields from item for the enhanced createWorkItem method const additionalFields = processor.extractAdditionalFields(item); // Create the work item using enhanced createWorkItem (inherits area/iteration paths) const createdItem = await client.createWorkItem( parentId, item.title, item.type, additionalFields ); // Store mapping and result idMapping.set(item.tempId, createdItem.id); created.push({ ...createdItem, tempId: item.tempId, level: item.level }); completed++; if (progressCallback) { progressCallback({ current: completed, total: totalItems, message: `Created ${item.type} #${createdItem.id}: ${item.title}`, item: createdItem }); } // Small delay to avoid overwhelming the API await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { errors.push({ tempId: item.tempId, type: item.type, title: item.title, error: error.message, level: item.level }); completed++; if (progressCallback) { progressCallback({ current: completed, total: totalItems, message: `Failed to create ${item.type}: ${item.title}`, error: true }); } } } return { success: errors.length === 0, created, errors, totalCreated: created.length, totalErrors: errors.length, idMapping: Object.fromEntries(idMapping) }; } /** * Bulk create work items from hierarchical JSON file * @param {string} filePath - Path to the JSON file * @param {Object} options - Command options */ export async function bulkCreateCommand(filePath, options = {}) { let spinner; try { // Validate file path if (!filePath) { throw new Error('JSON file path is required. Usage: devops-issues bulk-create <path-to-json>'); } // Check if file exists try { await fs.access(filePath); } catch (error) { throw new Error(`File not found: ${filePath}`); } // Load and validate configuration const config = getConfig(); if (!config.pat || !config.orgUrl || !config.project) { throw new Error('Azure DevOps configuration is incomplete. Please run setup first.'); } // Initialize processor and client spinner = ora('Initializing...').start(); const processor = new HierarchyProcessor(); await processor.initialize(); const client = new AzureDevOpsClient(config); await client.connect(); spinner.succeed('Connected to Azure DevOps'); // Load and parse JSON file spinner = ora('Loading hierarchy definition...').start(); const fileContent = await fs.readFile(filePath, 'utf8'); let hierarchyData; try { hierarchyData = JSON.parse(fileContent); } catch (error) { throw new Error(`Invalid JSON file: ${error.message}`); } spinner.succeed('Hierarchy definition loaded'); // Process the hierarchy spinner = ora('Processing hierarchy structure...').start(); const processedResult = await processor.processHierarchy(hierarchyData); spinner.succeed(`Processed ${processedResult.totalCount} work items`); // Generate JSON patch documents const patchDocuments = processor.generateJsonPatchDocuments(processedResult.workItems, processedResult.parentId); // Show preview if requested or if interactive if (options.preview || (!options.yes && !options.dryRun)) { console.log('\\n' + processor.createHierarchyPreview(processedResult.workItems)); } // Dry run mode - validate and preview only if (options.dryRun) { spinner = ora('Validating work item hierarchy...').start(); const validation = await client.validateWorkItemHierarchy(patchDocuments); if (validation.isValid) { spinner.succeed('āœ… Validation passed - hierarchy is ready for creation'); console.log(chalk.green('\\nāœ… Dry run completed successfully')); console.log(chalk.blue(`šŸ“Š Would create ${processedResult.totalCount} work items`)); return { success: true, dryRun: true }; } else { spinner.fail('āŒ Validation failed'); console.log(chalk.red('\\nāŒ Validation errors:')); validation.errors.forEach(error => console.log(chalk.red(` • ${error}`))); return { success: false, dryRun: true, errors: validation.errors }; } } // Confirm creation unless --yes flag is used if (!options.yes) { const { confirmCreate } = await import('inquirer').then(inquirer => inquirer.default.prompt([{ type: 'confirm', name: 'confirmCreate', message: `Create ${processedResult.totalCount} work items in Azure DevOps?`, default: false }]) ); if (!confirmCreate) { console.log(chalk.yellow('Operation cancelled')); return { success: false, cancelled: true }; } } // Validate before creation spinner = ora('Validating work item hierarchy...').start(); const validation = await client.validateWorkItemHierarchy(patchDocuments); if (!validation.isValid) { spinner.fail('āŒ Validation failed'); console.log(chalk.red('\\nāŒ Validation errors:')); validation.errors.forEach(error => console.log(chalk.red(` • ${error}`))); throw new Error('Hierarchy validation failed'); } spinner.succeed('āœ… Validation passed'); // Sort work items by level for sequential creation const sortedItems = processor.sortByLevel(processedResult.workItems); // Create work items sequentially by level console.log(chalk.blue('\\nšŸš€ Creating work items sequentially by level...')); const startTime = Date.now(); const result = await createWorkItemsSequentially( client, processor, sortedItems, processedResult.parentId, (progress) => { const percentage = Math.round((progress.current / progress.total) * 100); process.stdout.write(`\\r${chalk.blue('šŸ“')} ${progress.message} (${progress.current}/${progress.total} - ${percentage}%)`); } ); console.log(''); // New line after progress const duration = Math.round((Date.now() - startTime) / 1000); // Report results if (result.success && result.totalErrors === 0) { console.log(chalk.green(`\\nāœ… Successfully created ${result.totalCreated} work items in ${duration}s`)); if (options.verbose) { console.log(chalk.blue('\\nšŸ“‹ Created work items:')); result.created.forEach(item => { console.log(chalk.green(` āœ… ${item.type} #${item.id}: ${item.title}`)); }); } } else { console.log(chalk.yellow(`\\nāš ļø Partially completed: ${result.totalCreated} created, ${result.totalErrors} failed in ${duration}s`)); if (result.created.length > 0) { console.log(chalk.green('\\nāœ… Successfully created:')); result.created.forEach(item => { console.log(chalk.green(` āœ… ${item.type} #${item.id}: ${item.title}`)); }); } if (result.errors.length > 0) { console.log(chalk.red('\\nāŒ Failed to create:')); result.errors.forEach(error => { console.log(chalk.red(` āŒ ${error.type}: ${error.title || error.tempId} - ${error.error}`)); }); } } // Save ID mapping if requested if (options.saveMapping && result.idMapping) { const mappingFile = path.join(path.dirname(filePath), 'id-mapping.json'); await fs.writeFile(mappingFile, JSON.stringify(result.idMapping, null, 2)); console.log(chalk.blue(`\\nšŸ’¾ ID mapping saved to: ${mappingFile}`)); } return { success: result.success, created: result.created, errors: result.errors, totalCreated: result.totalCreated, totalErrors: result.totalErrors, duration, idMapping: result.idMapping }; } catch (error) { if (spinner) { spinner.fail(error.message); } else { console.error(chalk.red(`āŒ ${error.message}`)); } throw error; } } /** * Interactive hierarchy builder */ export async function interactiveHierarchyBuilder() { console.log(chalk.blue('šŸ”§ Interactive Hierarchy Builder')); console.log(chalk.gray('Build a work item hierarchy step by step\\n')); const inquirer = await import('inquirer').then(m => m.default); try { // Get basic information const { projectName, areaPath, iterationPath } = await inquirer.prompt([ { type: 'input', name: 'projectName', message: 'Project name:', validate: input => input.trim() ? true : 'Project name is required' }, { type: 'input', name: 'areaPath', message: 'Area path (optional):' }, { type: 'input', name: 'iterationPath', message: 'Iteration path (optional):' } ]); // Build hierarchy structure const hierarchy = { metadata: { project: projectName, ...(areaPath && { areaPath }), ...(iterationPath && { iterationPath }), defaults: {} }, workItems: [] }; // Get default assignee const { assignedTo } = await inquirer.prompt([ { type: 'input', name: 'assignedTo', message: 'Default assignee email (optional):' } ]); if (assignedTo) { hierarchy.metadata.defaults.assignedTo = assignedTo; } // Add root work items let addingItems = true; while (addingItems) { const workItem = await buildWorkItemInteractively(inquirer); hierarchy.workItems.push(workItem); const { addAnother } = await inquirer.prompt([ { type: 'confirm', name: 'addAnother', message: 'Add another top-level work item?', default: false } ]); addingItems = addAnother; } // Save hierarchy const { saveFile, fileName } = await inquirer.prompt([ { type: 'confirm', name: 'saveFile', message: 'Save hierarchy to file?', default: true }, { type: 'input', name: 'fileName', message: 'File name:', default: 'work-item-hierarchy.json', when: answers => answers.saveFile } ]); if (saveFile) { await fs.writeFile(fileName, JSON.stringify(hierarchy, null, 2)); console.log(chalk.green(`\\nāœ… Hierarchy saved to: ${fileName}`)); const { createNow } = await inquirer.prompt([ { type: 'confirm', name: 'createNow', message: 'Create work items now?', default: false } ]); if (createNow) { await bulkCreateCommand(fileName, { preview: true }); } } return hierarchy; } catch (error) { console.error(chalk.red(`āŒ Interactive builder failed: ${error.message}`)); throw error; } } /** * Build a single work item interactively * @param {Object} inquirer - Inquirer instance * @param {number} depth - Current depth for indentation * @returns {Object} Work item definition */ async function buildWorkItemInteractively(inquirer, depth = 0) { const indent = ' '.repeat(depth); console.log(chalk.blue(`${indent}āž• Adding work item (level ${depth + 1})`)); const { type, title, description } = await inquirer.prompt([ { type: 'list', name: 'type', message: `${indent}Work item type:`, choices: ['Epic', 'Feature', 'User Story', 'Task', 'Bug'] }, { type: 'input', name: 'title', message: `${indent}Title:`, validate: input => input.trim() ? true : 'Title is required' }, { type: 'input', name: 'description', message: `${indent}Description (optional):` } ]); const workItem = { type, title }; if (description) workItem.description = description; // Add type-specific fields if (type === 'User Story') { const { acceptanceCriteria } = await inquirer.prompt([ { type: 'input', name: 'acceptanceCriteria', message: `${indent}Acceptance criteria (optional):` } ]); if (acceptanceCriteria) workItem.acceptanceCriteria = acceptanceCriteria; } // Ask about children const canHaveChildren = ['Epic', 'Feature', 'User Story', 'Task'].includes(type); if (canHaveChildren && depth < 3) { // Limit depth const { addChildren } = await inquirer.prompt([ { type: 'confirm', name: 'addChildren', message: `${indent}Add child items to this ${type}?`, default: false } ]); if (addChildren) { workItem.children = []; let addingChildren = true; while (addingChildren) { const child = await buildWorkItemInteractively(inquirer, depth + 1); workItem.children.push(child); const { addAnother } = await inquirer.prompt([ { type: 'confirm', name: 'addAnother', message: `${indent}Add another child to this ${type}?`, default: false } ]); addingChildren = addAnother; } } } return workItem; }