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