@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
JavaScript
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;
}