UNPKG

@fromsvenwithlove/devops-issues-cli

Version:

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

538 lines (459 loc) 17 kB
import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { getChildWorkItemType, canHaveChildren } from './work-item-types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); /** * Processes and validates hierarchical work item JSON structures */ export class HierarchyProcessor { constructor() { this.ajv = new Ajv({ allErrors: true, strict: false }); addFormats(this.ajv); this.schema = null; this.tempIdCounter = 1; this.workItemMap = new Map(); } /** * Initialize the processor by loading the JSON schema */ async initialize() { try { const schemaPath = path.join(__dirname, '../schemas/work-item-hierarchy.json'); const schemaContent = await fs.readFile(schemaPath, 'utf8'); this.schema = JSON.parse(schemaContent); this.ajv.addSchema(this.schema, 'hierarchy'); } catch (error) { throw new Error(`Failed to load work item hierarchy schema: ${error.message}`); } } /** * Validate JSON structure against schema * @param {Object} data - The JSON data to validate * @returns {Object} Validation result with isValid and errors */ validateSchema(data) { if (!this.schema) { throw new Error('Processor not initialized. Call initialize() first.'); } const validate = this.ajv.getSchema('hierarchy'); const isValid = validate(data); return { isValid, errors: validate.errors || [] }; } /** * Process hierarchical JSON into flat structure with dependencies * @param {Object} hierarchyData - The validated hierarchy JSON * @returns {Object} Processed result with work items and metadata */ async processHierarchy(hierarchyData) { // Reset state this.tempIdCounter = 1; this.workItemMap.clear(); // First validate the schema const validation = this.validateSchema(hierarchyData); if (!validation.isValid) { throw new Error(`Invalid hierarchy structure: ${this.formatValidationErrors(validation.errors)}`); } // Extract metadata and defaults const metadata = hierarchyData.metadata || {}; const defaults = metadata.defaults || {}; // Validate parentId is present and valid if (!metadata.parentId) { throw new Error('parentId is required in metadata for all work item hierarchies'); } // Convert parentId to number if it's a string number const parentId = typeof metadata.parentId === 'string' ? (isNaN(metadata.parentId) ? metadata.parentId : parseInt(metadata.parentId, 10)) : metadata.parentId; if (typeof parentId !== 'number' && typeof parentId !== 'string') { throw new Error('parentId must be a number or string'); } // Update metadata with validated parentId metadata.parentId = parentId; // Process all work items recursively const processedItems = []; const creationOrder = []; for (const workItem of hierarchyData.workItems) { this.processWorkItemRecursively(workItem, null, metadata, defaults, processedItems, creationOrder); } // Validate hierarchy relationships this.validateHierarchyRelationships(processedItems); return { metadata, workItems: processedItems, creationOrder, totalCount: processedItems.length, parentId: metadata.parentId || null }; } /** * Process a single work item and its children recursively * @param {Object} workItem - Work item definition * @param {string} parentTempId - Parent's temporary ID * @param {Object} metadata - Global metadata * @param {Object} defaults - Default field values * @param {Array} processedItems - Array to store processed items * @param {Array} creationOrder - Array to store creation order */ processWorkItemRecursively(workItem, parentTempId, metadata, defaults, processedItems, creationOrder) { // Generate unique temporary ID const tempId = `temp_${this.tempIdCounter++}`; // Merge fields with inheritance const mergedFields = this.mergeFields(workItem, metadata, defaults); // Create processed work item const processedItem = { tempId, type: workItem.type, title: workItem.title, description: workItem.description || '', acceptanceCriteria: workItem.acceptanceCriteria, reproSteps: workItem.reproSteps, fields: mergedFields, parentTempId, children: [], level: parentTempId ? this.getItemLevel(parentTempId) + 1 : 0 }; // Add to maps and arrays this.workItemMap.set(tempId, processedItem); processedItems.push(processedItem); creationOrder.push(tempId); // Process children if (workItem.children && workItem.children.length > 0) { for (const child of workItem.children) { // Validate parent-child relationship this.validateParentChildType(workItem.type, child.type); const childTempId = this.processWorkItemRecursively( child, tempId, metadata, defaults, processedItems, creationOrder ); processedItem.children.push(childTempId); } } return tempId; } /** * Merge work item fields with inheritance rules * @param {Object} workItem - Work item definition * @param {Object} metadata - Global metadata * @param {Object} defaults - Default field values * @returns {Object} Merged fields */ mergeFields(workItem, metadata, defaults) { const fields = {}; // Apply defaults first (only if they have values) if (defaults) { Object.entries(defaults).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { fields[key] = value; } }); } // Apply global metadata (only if values exist) if (metadata.areaPath && metadata.areaPath.trim() !== '') { fields.areaPath = metadata.areaPath; } if (metadata.iterationPath && metadata.iterationPath.trim() !== '') { fields.iterationPath = metadata.iterationPath; } // Apply work item specific fields (overrides defaults and metadata) if (workItem.fields) { Object.entries(workItem.fields).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { fields[key] = value; } }); } // Set default state if not specified if (!fields.state) { fields.state = 'New'; } return fields; } /** * Get the level (depth) of a work item in the hierarchy * @param {string} tempId - Temporary ID of the work item * @returns {number} Level (0 = top level) */ getItemLevel(tempId) { const item = this.workItemMap.get(tempId); if (!item || !item.parentTempId) { return 0; } return this.getItemLevel(item.parentTempId) + 1; } /** * Validate parent-child work item type relationships * @param {string} parentType - Parent work item type * @param {string} childType - Child work item type */ validateParentChildType(parentType, childType) { const allowedChildType = getChildWorkItemType(parentType); // Special cases for flexible relationships const validRelationships = { 'Epic': ['Feature', 'User Story'], 'Feature': ['User Story'], 'User Story': ['Task', 'Bug'], 'Task': ['Task'], 'Bug': ['Task'] }; const allowedChildren = validRelationships[parentType] || []; if (!allowedChildren.includes(childType)) { throw new Error(`Invalid parent-child relationship: ${parentType} cannot have ${childType} children. Allowed children: ${allowedChildren.join(', ')}`); } } /** * Sort work items by hierarchy level (breadth-first order) * @param {Array} processedItems - All processed work items * @returns {Array} Work items sorted by level (0 = root, 1 = children of root, etc.) */ sortByLevel(processedItems) { // Group items by level const levelGroups = {}; processedItems.forEach(item => { const level = item.level || 0; if (!levelGroups[level]) { levelGroups[level] = []; } levelGroups[level].push(item); }); // Sort levels and flatten const sortedItems = []; const levels = Object.keys(levelGroups).map(Number).sort((a, b) => a - b); levels.forEach(level => { sortedItems.push(...levelGroups[level]); }); return sortedItems; } /** * Validate the entire hierarchy for consistency * @param {Array} processedItems - All processed work items */ validateHierarchyRelationships(processedItems) { for (const item of processedItems) { // Check if work item type can have children if (item.children.length > 0 && !canHaveChildren(item.type)) { throw new Error(`Work item type ${item.type} cannot have children`); } // Validate parent exists if parentTempId is set if (item.parentTempId && !this.workItemMap.has(item.parentTempId)) { throw new Error(`Parent work item ${item.parentTempId} not found for item ${item.tempId}`); } } } /** * Generate Azure DevOps JSON Patch documents for work item creation * @param {Array} processedItems - Processed work items * @param {string|number} globalParentId - Optional parent ID for linking root items * @returns {Array} Array of JSON Patch documents */ generateJsonPatchDocuments(processedItems, globalParentId = null) { const patchDocuments = []; for (const item of processedItems) { const patchDoc = this.createJsonPatchDocument(item); // Determine if this item should be linked to the global parent const isRootItem = !item.parentTempId; const shouldLinkToGlobalParent = isRootItem && globalParentId; patchDocuments.push({ tempId: item.tempId, type: item.type, patchDocument: patchDoc, parentTempId: item.parentTempId, globalParentId: shouldLinkToGlobalParent ? globalParentId : null }); } return patchDocuments; } /** * Create JSON Patch document for a single work item * @param {Object} item - Processed work item * @returns {Array} JSON Patch operations */ createJsonPatchDocument(item) { const operations = [ { "op": "add", "path": "/fields/System.Title", "value": item.title } ]; // Add description if provided if (item.description) { operations.push({ "op": "add", "path": "/fields/System.Description", "value": item.description }); } // Add acceptance criteria for User Stories if (item.acceptanceCriteria && item.type === 'User Story') { operations.push({ "op": "add", "path": "/fields/Microsoft.VSTS.Common.AcceptanceCriteria", "value": item.acceptanceCriteria }); } // Add reproduction steps for Bugs if (item.reproSteps && item.type === 'Bug') { operations.push({ "op": "add", "path": "/fields/Microsoft.VSTS.TCM.ReproSteps", "value": item.reproSteps }); } // Add all other fields for (const [fieldName, fieldValue] of Object.entries(item.fields)) { // Skip empty strings, undefined, and null values if (fieldValue !== undefined && fieldValue !== null && fieldValue !== '') { const systemFieldName = this.mapToSystemField(fieldName); // Skip assignedTo field if it looks like an invalid format // Azure DevOps requires either display name or valid email if (fieldName === 'assignedTo') { // Skip assignment if email format appears invalid or masked if (fieldValue.includes('*') || !this.isValidAssignedToValue(fieldValue)) { console.warn(`⚠️ Skipping invalid assignedTo value: "${fieldValue}" (prevents "unknown identity" error)`); continue; } } operations.push({ "op": "add", "path": `/fields/${systemFieldName}`, "value": fieldValue }); } } return operations; } /** * Validate if an assignedTo value is acceptable for Azure DevOps * @param {string} value - The assignedTo value to validate * @returns {boolean} True if valid, false otherwise */ isValidAssignedToValue(value) { if (!value || typeof value !== 'string') return false; // Check for masked/invalid email patterns if (value.includes('*')) return false; // Basic email format validation (simplified) const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // Accept if it looks like a proper email if (emailRegex.test(value)) return true; // Accept if it looks like a display name (contains letters/spaces, no @) if (!value.includes('@') && /^[a-zA-Z\s\-.,]+$/.test(value)) return true; // Accept if it's in "Display Name <email>" format const displayNameEmailRegex = /^.+\s+<[^\s@]+@[^\s@]+\.[^\s@]+>$/; if (displayNameEmailRegex.test(value)) return true; return false; } /** * Map custom field names to Azure DevOps system field names * @param {string} fieldName - Custom field name * @returns {string} System field name */ mapToSystemField(fieldName) { const fieldMapping = { 'assignedTo': 'System.AssignedTo', 'state': 'System.State', 'tags': 'System.Tags', 'severity': 'Microsoft.VSTS.Common.Severity' }; // Warn about organization-dependent fields const organizationDependentFields = [ 'severity' ]; if (organizationDependentFields.includes(fieldName)) { console.warn(`⚠️ Field "${fieldName}" may not be available in all process templates (Basic, Agile, Scrum, CMMI). Consider using only System.* fields for maximum compatibility.`); } return fieldMapping[fieldName] || fieldName; } /** * Format validation errors into readable message * @param {Array} errors - AJV validation errors * @returns {string} Formatted error message */ formatValidationErrors(errors) { return errors.map(error => { const path = error.instancePath || 'root'; return `${path}: ${error.message}`; }).join('; '); } /** * Create a preview of the hierarchy structure * @param {Array} processedItems - Processed work items * @returns {string} Text representation of hierarchy */ createHierarchyPreview(processedItems) { const rootItems = processedItems.filter(item => !item.parentTempId); let preview = '📋 Work Item Hierarchy Preview:\\n\\n'; for (const rootItem of rootItems) { preview += this.renderItemTree(rootItem, processedItems, 0); } preview += `\\n📊 Summary: ${processedItems.length} total work items`; return preview; } /** * Render a work item and its children as a tree * @param {Object} item - Work item to render * @param {Array} allItems - All processed items * @param {number} depth - Current depth for indentation * @returns {string} Tree representation */ renderItemTree(item, allItems, depth) { const indent = ' '.repeat(depth); const icon = this.getTypeIcon(item.type); const assignee = item.fields && item.fields.assignedTo ? ` (${item.fields.assignedTo})` : ''; let tree = `${indent}${icon} ${item.title}${assignee}\\n`; // Render children const children = allItems.filter(child => child.parentTempId === item.tempId); for (const child of children) { tree += this.renderItemTree(child, allItems, depth + 1); } return tree; } /** * Extract additional fields from a processed work item for createWorkItem method * @param {Object} item - Processed work item * @returns {Object} Additional fields object suitable for createWorkItem */ extractAdditionalFields(item) { const additionalFields = { description: item.description, acceptanceCriteria: item.acceptanceCriteria, reproSteps: item.reproSteps }; // Add all custom fields, excluding area/iteration paths (inherited automatically) if (item.fields) { Object.entries(item.fields).forEach(([key, value]) => { // Skip area and iteration paths as they are inherited from parent if (key !== 'areaPath' && key !== 'iterationPath' && value !== undefined && value !== null && value !== '') { additionalFields[key] = value; } }); } return additionalFields; } /** * Get emoji icon for work item type * @param {string} type - Work item type * @returns {string} Emoji icon */ getTypeIcon(type) { const icons = { 'Epic': '🎯', 'Feature': '🚀', 'User Story': '📖', 'Task': '✅', 'Bug': '🐛' }; return icons[type] || '📝'; } }