UNPKG

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