@fromsvenwithlove/devops-issues-cli
Version:
AI-powered CLI tool and library for Azure DevOps work item management with Claude agents
983 lines (855 loc) • 33.2 kB
JavaScript
import azdev from 'azure-devops-node-api';
import ora from 'ora';
import chalk from 'chalk';
import { CacheManager } from '../cache/index.js';
/**
* Utility function to chunk arrays into smaller batches
* @param {Array} array - Array to chunk
* @param {number} size - Size of each chunk (default: 200)
* @returns {Array[]} Array of chunks
*/
function chunkArray(array, size = 200) {
const chunks = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
export class AzureDevOpsClient {
constructor(config) {
this.config = config;
this.connection = null;
this.witApi = null;
this.cache = new CacheManager(config);
}
async connect() {
try {
const authHandler = azdev.getPersonalAccessTokenHandler(this.config.pat);
this.connection = new azdev.WebApi(this.config.orgUrl, authHandler);
this.witApi = await this.connection.getWorkItemTrackingApi();
} catch (error) {
throw new Error(`Failed to connect to Azure DevOps: ${error.message}`);
}
}
async getAssignedWorkItems(options = {}) {
const spinner = ora('Fetching work items...').start();
try {
if (this.config.rootIssueId) {
// Two-step process for Root Issue filtering
return await this.getRootIssueDescendants(options, spinner);
} else {
// Simple query for non-Root Issue filtering
return await this.getDirectAssignedItems(options, spinner);
}
} catch (error) {
spinner.fail('Failed to fetch work items');
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('404')) {
throw new Error('Project not found. Please check your project name.');
} else {
throw new Error(`Failed to fetch work items: ${error.message}`);
}
}
}
async getRootIssueDescendants(options, spinner) {
let workItems;
// Check cache first
if (await this.cache.isCacheValid()) {
spinner.text = 'Loading from cache...';
try {
workItems = await this.cache.getCachedDetails();
} catch (error) {
// Log cache error but continue to fetch from API
console.error('Cache read error:', error.message);
workItems = null;
}
if (workItems) {
spinner.succeed(`Loaded ${workItems.length} work items from cache`);
return this.filterAndFormatWorkItemsForExplorer(workItems, options);
}
}
// Cache miss or invalid - fetch from API
spinner.text = 'Fetching work items from API...';
// Step 1: Get all descendants of the Root Issue
const query = `
SELECT [System.Id]
FROM workitemLinks
WHERE [Source].[System.Id] = ${this.config.rootIssueId}
AND [System.Links.LinkType] = 'System.LinkTypes.Hierarchy-Forward'
AND [Target].[System.TeamProject] = '${this.config.project}'
ORDER BY [Target].[System.Id] DESC
MODE (Recursive)
`;
const result = await this.witApi.queryByWiql({ query }, this.config.project);
if (!result || !result.workItemRelations || result.workItemRelations.length === 0) {
spinner.succeed('No work items found in Root Issue');
return [];
}
// Step 2: Get detailed work item information including parent items
const workItemIds = result.workItemRelations
.filter(relation => relation.target && relation.target.id)
.map(relation => relation.target.id);
// Also include the root issue itself and any parent items
const allIds = [...new Set([this.config.rootIssueId, ...workItemIds])];
// Cache the hierarchy
await this.cache.setCachedHierarchy(allIds);
const fields = [
'System.Id',
'System.Title',
'System.State',
'System.WorkItemType',
'System.AssignedTo',
'System.Parent'
];
// Handle large datasets by chunking requests to avoid API limit
const batchSize = this.config.batchSize || 200;
if (allIds.length > batchSize) {
spinner.text = `Fetching ${allIds.length} work items in batches...`;
const chunks = chunkArray(allIds, batchSize);
const allWorkItems = [];
for (let i = 0; i < chunks.length; i++) {
spinner.text = `Fetching batch ${i + 1}/${chunks.length} (${chunks[i].length} items)...`;
const chunkWorkItems = await this.witApi.getWorkItems(chunks[i], fields);
allWorkItems.push(...chunkWorkItems);
}
workItems = allWorkItems;
} else {
workItems = await this.witApi.getWorkItems(allIds, fields);
}
if (!workItems || workItems.length === 0) {
spinner.succeed('No work items found');
return [];
}
// Cache the details and update metadata
await this.cache.setCachedDetails(workItems);
await this.cache.updateMetadata(workItems.length);
spinner.succeed(`Found ${workItems.length} work items (cached for future use)`);
return this.filterAndFormatWorkItemsForExplorer(workItems, options);
}
filterAndFormatWorkItems(workItems, options) {
// Step 3: Filter by assigned user and apply additional filters
let filteredItems = workItems.filter(item => {
const assignedTo = item.fields['System.AssignedTo'];
return assignedTo && assignedTo.uniqueName === this.config.user;
});
// Apply additional filters
if (options.multiState && Array.isArray(options.multiState)) {
filteredItems = filteredItems.filter(item =>
options.multiState.includes(item.fields['System.State'])
);
} else if (options.state) {
filteredItems = filteredItems.filter(item =>
item.fields['System.State'] === options.state
);
}
if (options.type) {
filteredItems = filteredItems.filter(item =>
item.fields['System.WorkItemType'] === options.type
);
}
// Apply limit
if (options.limit && options.limit > 0) {
filteredItems = filteredItems.slice(0, options.limit);
}
return filteredItems.map(item => ({
id: item.id,
title: item.fields['System.Title'],
state: item.fields['System.State'],
type: item.fields['System.WorkItemType'],
parentId: item.fields['System.Parent'],
assignedTo: item.fields['System.AssignedTo']?.displayName,
fields: item.fields
}));
}
filterAndFormatWorkItemsForExplorer(workItems, options) {
// For explorer, we want to show the hierarchy even if parents aren't assigned to user
// Apply filters but don't filter by assignedTo
let filteredItems = workItems;
// Apply state filters
if (options.multiState && Array.isArray(options.multiState)) {
filteredItems = filteredItems.filter(item =>
options.multiState.includes(item.fields['System.State'])
);
} else if (options.state) {
filteredItems = filteredItems.filter(item =>
item.fields['System.State'] === options.state
);
}
// Apply type filters
if (options.type) {
filteredItems = filteredItems.filter(item =>
item.fields['System.WorkItemType'] === options.type
);
}
// Apply limit
if (options.limit && options.limit > 0) {
filteredItems = filteredItems.slice(0, options.limit);
}
return filteredItems.map(item => ({
id: item.id,
title: item.fields['System.Title'],
state: item.fields['System.State'],
type: item.fields['System.WorkItemType'],
parentId: item.fields['System.Parent'],
assignedTo: item.fields['System.AssignedTo']?.displayName,
fields: item.fields
}));
}
async getDirectAssignedItems(options, spinner) {
// Simple query for non-Epic filtering
let query = `
SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType]
FROM WorkItems
WHERE [System.AssignedTo] = @Me
AND [System.TeamProject] = '${this.config.project}'
`;
// Add additional filters
if (options.multiState && Array.isArray(options.multiState)) {
const stateConditions = options.multiState.map(state => `[System.State] = '${state}'`).join(' OR ');
query += ` AND (${stateConditions})`;
} else if (options.state) {
query += ` AND [System.State] = '${options.state}'`;
}
if (options.type) {
query += ` AND [System.WorkItemType] = '${options.type}'`;
}
query += ` ORDER BY [System.Id] DESC`;
const result = await this.witApi.queryByWiql({ query }, this.config.project);
if (!result || !result.workItems || result.workItems.length === 0) {
spinner.succeed('No work items found');
return [];
}
// Apply limit
let workItemIds = result.workItems.map(wi => wi.id);
if (options.limit && options.limit > 0) {
workItemIds = workItemIds.slice(0, options.limit);
}
// Get detailed work item information
const fields = [
'System.Id',
'System.Title',
'System.State',
'System.WorkItemType',
'System.AssignedTo',
'System.Parent'
];
// Handle large datasets by chunking requests to avoid API limit
let workItems;
const batchSize = this.config.batchSize || 200;
if (workItemIds.length > batchSize) {
spinner.text = `Fetching ${workItemIds.length} work items in batches...`;
const chunks = chunkArray(workItemIds, batchSize);
const allWorkItems = [];
for (let i = 0; i < chunks.length; i++) {
spinner.text = `Fetching batch ${i + 1}/${chunks.length} (${chunks[i].length} items)...`;
const chunkWorkItems = await this.witApi.getWorkItems(chunks[i], fields);
allWorkItems.push(...chunkWorkItems);
}
workItems = allWorkItems;
} else {
workItems = await this.witApi.getWorkItems(workItemIds, fields);
}
if (!workItems || workItems.length === 0) {
spinner.succeed('No work items found');
return [];
}
spinner.succeed(`Found ${workItems.length} work item${workItems.length !== 1 ? 's' : ''}`);
return workItems.map(item => ({
id: item.id,
title: item.fields['System.Title'],
state: item.fields['System.State'],
type: item.fields['System.WorkItemType'],
parentId: item.fields['System.Parent'],
assignedTo: item.fields['System.AssignedTo']?.displayName,
fields: item.fields
}));
}
async getWorkItemTypes() {
try {
const types = await this.witApi.getWorkItemTypes(this.config.project);
return types.map(t => t.name);
} catch (error) {
throw new Error(`Failed to fetch work item types: ${error.message}`);
}
}
async getWorkItem(id) {
try {
const fields = [
'System.Id',
'System.Title',
'System.State',
'System.WorkItemType',
'System.AssignedTo',
'System.Parent',
'System.AreaPath',
'System.IterationPath'
];
const workItem = await this.witApi.getWorkItem(id, fields);
if (!workItem) {
throw new Error(`Work item ${id} not found`);
}
return {
id: workItem.id,
title: workItem.fields['System.Title'],
state: workItem.fields['System.State'],
type: workItem.fields['System.WorkItemType'],
parentId: workItem.fields['System.Parent'],
assignedTo: workItem.fields['System.AssignedTo']?.displayName,
areaPath: workItem.fields['System.AreaPath'],
iterationPath: workItem.fields['System.IterationPath'],
url: workItem._links?.html?.href,
fields: workItem.fields
};
} catch (error) {
if (error.message.includes('404')) {
throw new Error(`Work item ${id} not found`);
} else if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else {
throw new Error(`Failed to fetch work item ${id}: ${error.message}`);
}
}
}
async updateWorkItemState(id, newState) {
try {
// Build JSON patch document for state update
const patchDocument = [
{
"op": "replace",
"path": "/fields/System.State",
"value": newState
}
];
// Update the work item
const workItem = await this.witApi.updateWorkItem(
null, // custom headers
patchDocument, // JSON patch document
id, // work item id
this.config.project, // project name
false, // validateOnly
false, // bypassRules
false // suppressNotifications
);
// Clear cache since we've updated a work item
try {
await this.cache.clearCache();
} catch (cacheError) {
// Don't fail if cache clear fails, just warn
console.warn('Warning: Failed to clear cache after work item update');
}
return {
id: workItem.id,
title: workItem.fields['System.Title'],
type: workItem.fields['System.WorkItemType'],
state: workItem.fields['System.State'],
url: workItem._links?.html?.href
};
} catch (error) {
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('403')) {
throw new Error('Permission denied. You may not have rights to update this work item.');
} else if (error.message.includes('404')) {
throw new Error(`Work item ${id} not found.`);
} else if (error.message.includes('400')) {
throw new Error(`Invalid state transition: ${error.message}`);
} else {
throw new Error(`Failed to update work item state: ${error.message}`);
}
}
}
async getValidStates(workItemType) {
try {
const workItemTypeDetails = await this.witApi.getWorkItemType(this.config.project, workItemType);
if (!workItemTypeDetails || !workItemTypeDetails.states) {
return ['New', 'Active', 'Resolved', 'Closed']; // Fallback default states
}
return workItemTypeDetails.states.map(state => state.name);
} catch (error) {
// If we can't get specific states, return common default states
console.warn('Warning: Could not fetch valid states, using defaults');
return ['New', 'Active', 'Resolved', 'Closed'];
}
}
async deleteWorkItem(id) {
try {
// Delete the work item (soft delete by default)
const deletedItem = await this.witApi.deleteWorkItem(id, this.config.project);
// Clear cache since we've deleted a work item
try {
await this.cache.clearCache();
} catch (cacheError) {
// Don't fail if cache clear fails, just warn
console.warn('Warning: Failed to clear cache after work item deletion');
}
return {
id: deletedItem.id,
title: deletedItem.fields ? deletedItem.fields['System.Title'] : `Work Item #${id}`,
deleted: true,
deletedDate: new Date().toISOString()
};
} catch (error) {
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('403')) {
throw new Error('Permission denied. You may not have rights to delete this work item.');
} else if (error.message.includes('404')) {
throw new Error(`Work item ${id} not found or already deleted.`);
} else if (error.message.includes('400')) {
throw new Error(`Invalid delete request: ${error.message}`);
} else {
throw new Error(`Failed to delete work item: ${error.message}`);
}
}
}
async addWorkItemComment(id, commentText) {
try {
// Create comment object
const comment = {
text: commentText
};
// Add comment to work item
const addedComment = await this.witApi.addComment(comment, this.config.project, id);
// Clear cache since we've modified a work item
try {
await this.cache.clearCache();
} catch (cacheError) {
// Don't fail if cache clear fails, just warn
console.warn('Warning: Failed to clear cache after adding comment');
}
return {
id: addedComment.id,
workItemId: id,
text: addedComment.text,
createdDate: addedComment.createdDate,
createdBy: addedComment.createdBy?.displayName || 'Unknown'
};
} catch (error) {
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('403')) {
throw new Error('Permission denied. You may not have rights to comment on this work item.');
} else if (error.message.includes('404')) {
throw new Error(`Work item ${id} not found.`);
} else if (error.message.includes('400')) {
throw new Error(`Invalid comment request: ${error.message}`);
} else {
throw new Error(`Failed to add comment: ${error.message}`);
}
}
}
async getWorkItemComments(id) {
try {
// Get comments for the work item
const response = await this.witApi.getComments(this.config.project, id);
// The Azure DevOps API returns comments in a 'comments' property
const comments = response.comments || response;
if (!comments || !Array.isArray(comments) || comments.length === 0) {
return [];
}
// Format and return comments
return comments.map(comment => ({
id: comment.id,
text: comment.text,
createdDate: comment.createdDate,
createdBy: comment.createdBy?.displayName || 'Unknown',
modifiedDate: comment.modifiedDate,
modifiedBy: comment.modifiedBy?.displayName
})).sort((a, b) => new Date(b.createdDate) - new Date(a.createdDate)); // Sort newest first
} catch (error) {
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('403')) {
throw new Error('Permission denied. You may not have rights to view comments for this work item.');
} else if (error.message.includes('404')) {
throw new Error(`Work item ${id} not found.`);
} else {
throw new Error(`Failed to fetch comments: ${error.message}`);
}
}
}
/**
* 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;
}
/**
* Create a work item from a JSON patch document
* @param {Array} patchDocument - JSON patch operations
* @param {string} workItemType - Type of work item to create
* @param {number} parentId - Required parent ID for hierarchy relationship
* @returns {Object} Created work item with id, title, type, state, and url
*/
async createWorkItemFromPatch(patchDocument, workItemType, parentId) {
const spinner = ora(`Creating ${workItemType}...`).start();
try {
// Validate that parentId is provided
if (!parentId) {
throw new Error('parentId is required for all work item creation');
}
// Add parent relationship
patchDocument.push({
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Reverse",
"url": `${this.config.orgUrl}/_apis/wit/workitems/${parentId}`,
"attributes": {
"name": "Parent"
}
}
});
// Create the work item
const workItem = await this.witApi.createWorkItem(
null, // custom headers
patchDocument, // JSON patch document
this.config.project, // project name
workItemType, // work item type
false, // validateOnly
false, // bypassRules
false // suppressNotifications
);
const title = workItem.fields['System.Title'];
spinner.succeed(`Created ${workItemType} #${workItem.id}: ${title}`);
// Clear cache since we've added a new work item
try {
await this.cache.clearCache();
} catch (cacheError) {
// Don't fail if cache clear fails
console.warn('Warning: Failed to clear cache after work item creation');
}
return {
id: workItem.id,
title: workItem.fields['System.Title'],
type: workItem.fields['System.WorkItemType'],
state: workItem.fields['System.State'],
url: workItem._links?.html?.href,
areaPath: workItem.fields['System.AreaPath'],
iterationPath: workItem.fields['System.IterationPath']
};
} catch (error) {
spinner.fail(`Failed to create ${workItemType}`);
throw error;
}
}
async createWorkItem(parentId, title, childType, additionalFields = {}) {
const spinner = ora('Creating work item...').start();
try {
// Get parent work item to inherit some properties
const parent = await this.getWorkItem(parentId);
// Build JSON patch document for work item creation
const patchDocument = [
{
"op": "add",
"path": "/fields/System.Title",
"value": title
},
{
"op": "add",
"path": "/fields/System.State",
"value": "New"
}
];
// Add description if provided
if (additionalFields.description) {
patchDocument.push({
"op": "add",
"path": "/fields/System.Description",
"value": additionalFields.description
});
}
// Add acceptance criteria for User Stories
if (additionalFields.acceptanceCriteria && childType === 'User Story') {
patchDocument.push({
"op": "add",
"path": "/fields/Microsoft.VSTS.Common.AcceptanceCriteria",
"value": additionalFields.acceptanceCriteria
});
}
// Add reproduction steps for Bugs
if (additionalFields.reproSteps && childType === 'Bug') {
patchDocument.push({
"op": "add",
"path": "/fields/Microsoft.VSTS.TCM.ReproSteps",
"value": additionalFields.reproSteps
});
}
// Add other additional fields
if (additionalFields.tags) {
patchDocument.push({
"op": "add",
"path": "/fields/System.Tags",
"value": additionalFields.tags
});
}
// Add assignedTo if provided and valid
if (additionalFields.assignedTo && this.isValidAssignedToValue(additionalFields.assignedTo)) {
patchDocument.push({
"op": "add",
"path": "/fields/System.AssignedTo",
"value": additionalFields.assignedTo
});
}
// Inherit area path from parent if available
if (parent.areaPath) {
patchDocument.push({
"op": "add",
"path": "/fields/System.AreaPath",
"value": parent.areaPath
});
}
// Inherit iteration path from parent if available
if (parent.iterationPath) {
patchDocument.push({
"op": "add",
"path": "/fields/System.IterationPath",
"value": parent.iterationPath
});
}
// Add parent-child relationship
patchDocument.push({
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Reverse",
"url": `${this.config.orgUrl}/_apis/wit/workitems/${parentId}`,
"attributes": {
"name": "Parent"
}
}
});
// Create the work item
const workItem = await this.witApi.createWorkItem(
null, // custom headers
patchDocument, // JSON patch document
this.config.project, // project name
childType, // work item type
false, // validateOnly
false, // bypassRules
false // suppressNotifications
);
spinner.succeed(`Created ${childType} #${workItem.id}: ${title}`);
// Clear cache since we've added a new work item
try {
await this.cache.clearCache();
} catch (cacheError) {
// Don't fail if cache clear fails
console.warn('Warning: Failed to clear cache after work item creation');
}
return {
id: workItem.id,
title: workItem.fields['System.Title'],
type: workItem.fields['System.WorkItemType'],
state: workItem.fields['System.State'],
url: workItem._links?.html?.href,
parentId: parentId
};
} catch (error) {
spinner.fail('Failed to create work item');
if (error.message.includes('401')) {
throw new Error('Authentication failed. Please check your Personal Access Token.');
} else if (error.message.includes('403')) {
throw new Error('Permission denied. You may not have rights to create work items in this project.');
} else if (error.message.includes('400')) {
throw new Error(`Invalid request: ${error.message}`);
} else {
throw new Error(`Failed to create work item: ${error.message}`);
}
}
}
/**
* Create multiple work items in hierarchy using batch processing
* @param {Array} workItemPatchDocs - Array of patch documents with metadata
* @param {Function} progressCallback - Optional callback for progress updates
* @returns {Object} Result with created items and any errors
*/
async createWorkItemHierarchy(workItemPatchDocs, progressCallback = null) {
const totalItems = workItemPatchDocs.length;
const createdItems = new Map();
const errors = [];
const idMapping = new Map(); // Maps tempId to actual Azure DevOps ID
// Progress tracking
let completed = 0;
const updateProgress = (message, item = null) => {
completed++;
if (progressCallback) {
progressCallback({
current: completed,
total: totalItems,
message,
item
});
}
};
try {
// Create work items in dependency order (parents before children)
for (const workItemDoc of workItemPatchDocs) {
try {
// Add parent relationship if this item has a parent
let patchDocument = [...workItemDoc.patchDocument];
// Add relationship to internal hierarchy parent (tempId)
if (workItemDoc.parentTempId) {
const parentActualId = idMapping.get(workItemDoc.parentTempId);
if (parentActualId) {
patchDocument.push({
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Reverse",
"url": `${this.config.orgUrl}/_apis/wit/workitems/${parentActualId}`,
"attributes": {
"name": "Parent"
}
}
});
}
}
// Add relationship to global parent (existing work item)
if (workItemDoc.globalParentId) {
patchDocument.push({
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Hierarchy-Reverse",
"url": `${this.config.orgUrl}/_apis/wit/workitems/${workItemDoc.globalParentId}`,
"attributes": {
"name": "Parent"
}
}
});
}
// Create the work item
const workItem = await this.witApi.createWorkItem(
null, // custom headers
patchDocument, // JSON patch document
this.config.project, // project name
workItemDoc.type, // work item type
false, // validateOnly
false, // bypassRules
false // suppressNotifications
);
// Store the mapping and result
idMapping.set(workItemDoc.tempId, workItem.id);
const createdItem = {
tempId: workItemDoc.tempId,
id: workItem.id,
title: workItem.fields['System.Title'],
type: workItem.fields['System.WorkItemType'],
state: workItem.fields['System.State'],
url: workItem._links?.html?.href,
parentId: workItemDoc.parentTempId ? idMapping.get(workItemDoc.parentTempId) : null
};
createdItems.set(workItemDoc.tempId, createdItem);
updateProgress(`Created ${workItem.fields['System.WorkItemType']} #${workItem.id}`, createdItem);
// Small delay to avoid overwhelming the API
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
const errorInfo = {
tempId: workItemDoc.tempId,
type: workItemDoc.type,
title: this.extractTitleFromPatch(workItemDoc.patchDocument),
error: error.message
};
errors.push(errorInfo);
updateProgress(`Failed to create ${workItemDoc.type}`, errorInfo);
}
}
// Clear cache since we've added new work items
try {
await this.cache.clearCache();
} catch (cacheError) {
console.warn('Warning: Failed to clear cache after work item creation');
}
return {
success: true,
created: Array.from(createdItems.values()),
errors,
totalCreated: createdItems.size,
totalErrors: errors.length,
idMapping: Object.fromEntries(idMapping)
};
} catch (error) {
return {
success: false,
created: Array.from(createdItems.values()),
errors: [...errors, { error: `Batch creation failed: ${error.message}` }],
totalCreated: createdItems.size,
totalErrors: errors.length + 1,
idMapping: Object.fromEntries(idMapping)
};
}
}
/**
* Extract title from JSON patch document for error reporting
* @param {Array} patchDocument - JSON patch operations
* @returns {string} Work item title or fallback
*/
extractTitleFromPatch(patchDocument) {
const titleOp = patchDocument.find(op => op.path === '/fields/System.Title');
return titleOp ? titleOp.value : 'Unknown Title';
}
/**
* Validate a work item hierarchy before creation
* @param {Array} workItemPatchDocs - Array of patch documents to validate
* @returns {Object} Validation result
*/
async validateWorkItemHierarchy(workItemPatchDocs) {
const validationErrors = [];
const tempIdSet = new Set();
// Check for duplicate temp IDs
for (const doc of workItemPatchDocs) {
if (tempIdSet.has(doc.tempId)) {
validationErrors.push(`Duplicate temporary ID: ${doc.tempId}`);
}
tempIdSet.add(doc.tempId);
}
// Validate parent references
for (const doc of workItemPatchDocs) {
if (doc.parentTempId && !tempIdSet.has(doc.parentTempId)) {
validationErrors.push(`Parent temporary ID ${doc.parentTempId} not found for item ${doc.tempId}`);
}
}
// Validate global parent exists (if specified)
const globalParentIds = [...new Set(workItemPatchDocs
.filter(doc => doc.globalParentId)
.map(doc => doc.globalParentId))];
for (const parentId of globalParentIds) {
try {
await this.getWorkItem(parentId);
} catch (error) {
validationErrors.push(`Global parent work item ${parentId} not found or not accessible`);
}
}
// Validate work item types exist
try {
const availableTypes = await this.getWorkItemTypes();
for (const doc of workItemPatchDocs) {
if (!availableTypes.includes(doc.type)) {
validationErrors.push(`Invalid work item type: ${doc.type}`);
}
}
} catch (error) {
validationErrors.push(`Could not validate work item types: ${error.message}`);
}
return {
isValid: validationErrors.length === 0,
errors: validationErrors
};
}
}