From 39e504f117675d271d6e0cc94e2c027280d421f0 Mon Sep 17 00:00:00 2001 From: Micah Rairdon Date: Sun, 30 Mar 2025 20:25:56 -0400 Subject: [PATCH] feat: implement manage work item link handler Add feature to manage work item links Allows adding, removing, and updating links between work items --- README.md | 3 + project-management/task-management/doing.md | 1 + project-management/task-management/done.md | 40 +++++ src/features/work-items/index.ts | 1 + .../manage-work-item-link/feature.spec.int.ts | 135 ++++++++++++++ .../feature.spec.unit.ts | 166 ++++++++++++++++++ .../manage-work-item-link/feature.ts | 119 +++++++++++++ .../work-items/manage-work-item-link/index.ts | 2 + .../manage-work-item-link/schema.ts | 3 + src/features/work-items/schemas.ts | 25 +++ src/server.ts | 21 +++ 11 files changed, 516 insertions(+) create mode 100644 src/features/work-items/manage-work-item-link/feature.spec.int.ts create mode 100644 src/features/work-items/manage-work-item-link/feature.spec.unit.ts create mode 100644 src/features/work-items/manage-work-item-link/feature.ts create mode 100644 src/features/work-items/manage-work-item-link/index.ts create mode 100644 src/features/work-items/manage-work-item-link/schema.ts diff --git a/README.md b/README.md index dc71076..5ca246d 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ For repository-specific tool documentation, see the [Repositories Tools Guide](d - `get_work_item`: Retrieve a work item by ID - `create_work_item`: Create a new work item +- `update_work_item`: Update an existing work item +- `list_work_items`: List work items in a project +- `manage_work_item_link`: Add, remove, or update links between work items ## Contributing diff --git a/project-management/task-management/doing.md b/project-management/task-management/doing.md index e69de29..8b13789 100644 --- a/project-management/task-management/doing.md +++ b/project-management/task-management/doing.md @@ -0,0 +1 @@ + diff --git a/project-management/task-management/done.md b/project-management/task-management/done.md index 50c63ec..13bc975 100644 --- a/project-management/task-management/done.md +++ b/project-management/task-management/done.md @@ -1,5 +1,45 @@ ## Completed Tasks +- [x] **Task 1.0**: Implement `manage_work_item_link` handler with tests + - **Role**: Full-Stack Developer + - **Phase**: Completed + - **Notes**: + - Work item links in Azure DevOps represent relationships between work items + - Common link types include: + - Hierarchy links (Parent/Child) + - Related links + - Dependency links (Predecessor/Successor) + - Custom link types specific to the organization + - Azure DevOps API provides the following for work item links: + - Work Item Relation Types API: Lists available link types + - Work Items API: Can create/update work items with links via JSON Patch operations + - Reporting Work Item Links API: Can retrieve work item link information + - Creating a link requires: + - Source work item ID + - Target work item ID + - Relation type reference name (e.g., "System.LinkTypes.Hierarchy-Forward") + - Links are managed via JSON Patch document with operations like: + - "add" operation to add a link + - "remove" operation to remove a link + - The `manage_work_item_link` handler supports: + - Adding a link between two work items + - Removing a link between two work items + - Updating a link type (removing existing and adding new) + - Implementation: + - Created schema for handler parameters + - Implemented handler function that creates the appropriate JSON patch document + - Added proper error handling and validation + - Added unit and integration tests + - Updated documentation + - **Sub-tasks**: + - [x] Research Azure DevOps API for work item link management + - [x] Define schema for the handler (inputs and outputs) + - [x] Create test cases for the handler + - [x] Implement the handler + - [x] Ensure proper error handling and validation + - [x] Write and run both unit and integration tests + - **Completed**: March 31, 2024 + - [x] **Task 2.6**: Implement `list_work_items` handler with tests - **Role**: Full-Stack Developer diff --git a/src/features/work-items/index.ts b/src/features/work-items/index.ts index 7d6442b..97e71d0 100644 --- a/src/features/work-items/index.ts +++ b/src/features/work-items/index.ts @@ -7,3 +7,4 @@ export * from './list-work-items'; export * from './get-work-item'; export * from './create-work-item'; export * from './update-work-item'; +export * from './manage-work-item-link'; diff --git a/src/features/work-items/manage-work-item-link/feature.spec.int.ts b/src/features/work-items/manage-work-item-link/feature.spec.int.ts new file mode 100644 index 0000000..84ff067 --- /dev/null +++ b/src/features/work-items/manage-work-item-link/feature.spec.int.ts @@ -0,0 +1,135 @@ +import { WebApi } from 'azure-devops-node-api'; +import { manageWorkItemLink } from './feature'; +import { createWorkItem } from '../create-work-item/feature'; +import { + getTestConnection, + shouldSkipIntegrationTest, +} from '../../../shared/test/test-helpers'; +import { CreateWorkItemOptions } from '../types'; + +// Note: These tests will be skipped in CI due to missing credentials +// They are meant to be run manually in a dev environment with proper Azure DevOps setup +describe('manageWorkItemLink integration', () => { + let connection: WebApi | null = null; + let projectName: string; + let sourceWorkItemId: number | null = null; + let targetWorkItemId: number | null = null; + + beforeAll(async () => { + // Get a real connection using environment variables + connection = await getTestConnection(); + projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; + + // Skip setup if integration tests should be skipped + if (shouldSkipIntegrationTest() || !connection) { + return; + } + + try { + // Create source work item for link tests + const sourceOptions: CreateWorkItemOptions = { + title: `Source Work Item for Link Tests ${new Date().toISOString()}`, + description: + 'Source work item for integration tests of manage-work-item-link', + }; + + const sourceWorkItem = await createWorkItem( + connection, + projectName, + 'Task', + sourceOptions, + ); + + // Create target work item for link tests + const targetOptions: CreateWorkItemOptions = { + title: `Target Work Item for Link Tests ${new Date().toISOString()}`, + description: + 'Target work item for integration tests of manage-work-item-link', + }; + + const targetWorkItem = await createWorkItem( + connection, + projectName, + 'Task', + targetOptions, + ); + + // Store the work item IDs for the tests + if (sourceWorkItem && sourceWorkItem.id !== undefined) { + sourceWorkItemId = sourceWorkItem.id; + } + if (targetWorkItem && targetWorkItem.id !== undefined) { + targetWorkItemId = targetWorkItem.id; + } + } catch (error) { + console.error('Failed to create work items for link tests:', error); + } + }); + + test('should add a link between two existing work items', async () => { + // Skip if integration tests should be skipped or if work items weren't created + if ( + shouldSkipIntegrationTest() || + !connection || + !sourceWorkItemId || + !targetWorkItemId + ) { + return; + } + + // Act & Assert - should not throw + const result = await manageWorkItemLink(connection, projectName, { + sourceWorkItemId, + targetWorkItemId, + operation: 'add', + relationType: 'System.LinkTypes.Related', + comment: 'Link created by integration test', + }); + + // Assert + expect(result).toBeDefined(); + expect(result.id).toBe(sourceWorkItemId); + }); + + test('should handle non-existent work items gracefully', async () => { + // Skip if integration tests should be skipped or if no connection + if (shouldSkipIntegrationTest() || !connection) { + return; + } + + // Use a very large ID that's unlikely to exist + const nonExistentId = 999999999; + + // Act & Assert - should throw an error for non-existent work item + await expect( + manageWorkItemLink(connection, projectName, { + sourceWorkItemId: nonExistentId, + targetWorkItemId: nonExistentId, + operation: 'add', + relationType: 'System.LinkTypes.Related', + }), + ).rejects.toThrow(/[Ww]ork [Ii]tem.*not found|does not exist/); + }); + + test('should handle non-existent relationship types gracefully', async () => { + // Skip if integration tests should be skipped or if work items weren't created + if ( + shouldSkipIntegrationTest() || + !connection || + !sourceWorkItemId || + !targetWorkItemId + ) { + return; + } + + // Act & Assert - should throw an error for non-existent relation type + await expect( + manageWorkItemLink(connection, projectName, { + sourceWorkItemId, + targetWorkItemId, + operation: 'add', + relationType: 'NonExistentLinkType', + }), + ).rejects.toThrow(/[Rr]elation|[Ll]ink|[Tt]ype/); // Error may vary, but should mention relation/link/type + }); +}); diff --git a/src/features/work-items/manage-work-item-link/feature.spec.unit.ts b/src/features/work-items/manage-work-item-link/feature.spec.unit.ts new file mode 100644 index 0000000..c68f8ab --- /dev/null +++ b/src/features/work-items/manage-work-item-link/feature.spec.unit.ts @@ -0,0 +1,166 @@ +import { manageWorkItemLink } from './feature'; +import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; + +describe('manageWorkItemLink', () => { + let mockConnection: any; + let mockWitApi: any; + + const projectId = 'test-project'; + const sourceWorkItemId = 123; + const targetWorkItemId = 456; + const relationType = 'System.LinkTypes.Related'; + const newRelationType = 'System.LinkTypes.Hierarchy-Forward'; + const comment = 'Test link comment'; + + beforeEach(() => { + mockWitApi = { + updateWorkItem: jest.fn(), + }; + + mockConnection = { + getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWitApi), + serverUrl: 'https://dev.azure.com/test-org', + }; + }); + + test('should add a work item link', async () => { + // Setup + const updatedWorkItem = { + id: sourceWorkItemId, + fields: { 'System.Title': 'Test' }, + }; + mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); + + // Execute + const result = await manageWorkItemLink(mockConnection, projectId, { + sourceWorkItemId, + targetWorkItemId, + operation: 'add', + relationType, + comment, + }); + + // Verify + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, // customHeaders + [ + { + op: 'add', + path: '/relations/-', + value: { + rel: relationType, + url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`, + attributes: { comment }, + }, + }, + ], + sourceWorkItemId, + projectId, + ); + expect(result).toEqual(updatedWorkItem); + }); + + test('should remove a work item link', async () => { + // Setup + const updatedWorkItem = { + id: sourceWorkItemId, + fields: { 'System.Title': 'Test' }, + }; + mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); + + // Execute + const result = await manageWorkItemLink(mockConnection, projectId, { + sourceWorkItemId, + targetWorkItemId, + operation: 'remove', + relationType, + }); + + // Verify + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, // customHeaders + [ + { + op: 'remove', + path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`, + }, + ], + sourceWorkItemId, + projectId, + ); + expect(result).toEqual(updatedWorkItem); + }); + + test('should update a work item link', async () => { + // Setup + const updatedWorkItem = { + id: sourceWorkItemId, + fields: { 'System.Title': 'Test' }, + }; + mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem); + + // Execute + const result = await manageWorkItemLink(mockConnection, projectId, { + sourceWorkItemId, + targetWorkItemId, + operation: 'update', + relationType, + newRelationType, + comment, + }); + + // Verify + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, // customHeaders + [ + { + op: 'remove', + path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`, + }, + { + op: 'add', + path: '/relations/-', + value: { + rel: newRelationType, + url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`, + attributes: { comment }, + }, + }, + ], + sourceWorkItemId, + projectId, + ); + expect(result).toEqual(updatedWorkItem); + }); + + test('should throw error when work item not found', async () => { + // Setup + mockWitApi.updateWorkItem.mockResolvedValue(null); + + // Execute and verify + await expect( + manageWorkItemLink(mockConnection, projectId, { + sourceWorkItemId, + targetWorkItemId, + operation: 'add', + relationType, + }), + ).rejects.toThrow(AzureDevOpsResourceNotFoundError); + }); + + test('should throw error when update operation missing newRelationType', async () => { + // Execute and verify + await expect( + manageWorkItemLink(mockConnection, projectId, { + sourceWorkItemId, + targetWorkItemId, + operation: 'update', + relationType, + // newRelationType is missing + }), + ).rejects.toThrow('New relation type is required for update operation'); + }); +}); diff --git a/src/features/work-items/manage-work-item-link/feature.ts b/src/features/work-items/manage-work-item-link/feature.ts new file mode 100644 index 0000000..dd6d039 --- /dev/null +++ b/src/features/work-items/manage-work-item-link/feature.ts @@ -0,0 +1,119 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { WorkItem } from '../types'; + +/** + * Options for managing work item link + */ +interface ManageWorkItemLinkOptions { + sourceWorkItemId: number; + targetWorkItemId: number; + operation: 'add' | 'remove' | 'update'; + relationType: string; + newRelationType?: string; + comment?: string; +} + +/** + * Manage (add, remove, or update) a link between two work items + * + * @param connection The Azure DevOps WebApi connection + * @param projectId The ID or name of the project + * @param options Options for managing the work item link + * @returns The updated source work item + * @throws {AzureDevOpsResourceNotFoundError} If either work item is not found + */ +export async function manageWorkItemLink( + connection: WebApi, + projectId: string, + options: ManageWorkItemLinkOptions, +): Promise { + try { + const { + sourceWorkItemId, + targetWorkItemId, + operation, + relationType, + newRelationType, + comment, + } = options; + + // Input validation + if (!sourceWorkItemId) { + throw new Error('Source work item ID is required'); + } + + if (!targetWorkItemId) { + throw new Error('Target work item ID is required'); + } + + if (!relationType) { + throw new Error('Relation type is required'); + } + + if (operation === 'update' && !newRelationType) { + throw new Error('New relation type is required for update operation'); + } + + const witApi = await connection.getWorkItemTrackingApi(); + + // Create the JSON patch document + const document = []; + + // Construct the relationship URL + const relationshipUrl = `${connection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`; + + if (operation === 'add' || operation === 'update') { + // For 'update', we'll first remove the old link, then add the new one + if (operation === 'update') { + document.push({ + op: 'remove', + path: `/relations/+[rel=${relationType};url=${relationshipUrl}]`, + }); + } + + // Add the new relationship + document.push({ + op: 'add', + path: '/relations/-', + value: { + rel: operation === 'update' ? newRelationType : relationType, + url: relationshipUrl, + ...(comment ? { attributes: { comment } } : {}), + }, + }); + } else if (operation === 'remove') { + // Remove the relationship + document.push({ + op: 'remove', + path: `/relations/+[rel=${relationType};url=${relationshipUrl}]`, + }); + } + + // Update the work item with the new relationship + const updatedWorkItem = await witApi.updateWorkItem( + {}, // customHeaders + document, + sourceWorkItemId, + projectId, + ); + + if (!updatedWorkItem) { + throw new AzureDevOpsResourceNotFoundError( + `Work item '${sourceWorkItemId}' not found`, + ); + } + + return updatedWorkItem; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to manage work item link: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/work-items/manage-work-item-link/index.ts b/src/features/work-items/manage-work-item-link/index.ts new file mode 100644 index 0000000..ad6d04e --- /dev/null +++ b/src/features/work-items/manage-work-item-link/index.ts @@ -0,0 +1,2 @@ +export { manageWorkItemLink } from './feature'; +export { ManageWorkItemLinkSchema } from './schema'; diff --git a/src/features/work-items/manage-work-item-link/schema.ts b/src/features/work-items/manage-work-item-link/schema.ts new file mode 100644 index 0000000..6f5c64c --- /dev/null +++ b/src/features/work-items/manage-work-item-link/schema.ts @@ -0,0 +1,3 @@ +import { ManageWorkItemLinkSchema } from '../schemas'; + +export { ManageWorkItemLinkSchema }; diff --git a/src/features/work-items/schemas.ts b/src/features/work-items/schemas.ts index 8175fa6..574038f 100644 --- a/src/features/work-items/schemas.ts +++ b/src/features/work-items/schemas.ts @@ -86,3 +86,28 @@ export const UpdateWorkItemSchema = z.object({ .optional() .describe('Additional fields to update on the work item'), }); + +/** + * Schema for managing work item links + */ +export const ManageWorkItemLinkSchema = z.object({ + sourceWorkItemId: z.number().describe('The ID of the source work item'), + targetWorkItemId: z.number().describe('The ID of the target work item'), + projectId: z.string().describe('The ID or name of the project'), + operation: z + .enum(['add', 'remove', 'update']) + .describe('The operation to perform on the link'), + relationType: z + .string() + .describe( + 'The reference name of the relation type (e.g., "System.LinkTypes.Hierarchy-Forward")', + ), + newRelationType: z + .string() + .optional() + .describe('The new relation type to use when updating a link'), + comment: z + .string() + .optional() + .describe('Optional comment explaining the link'), +}); diff --git a/src/server.ts b/src/server.ts index 26082e9..9b4e40f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,10 +22,12 @@ import { GetWorkItemSchema, CreateWorkItemSchema, UpdateWorkItemSchema, + ManageWorkItemLinkSchema, listWorkItems, getWorkItem, createWorkItem, updateWorkItem, + manageWorkItemLink, } from './features/work-items'; import { @@ -118,6 +120,11 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { description: 'Update an existing work item', inputSchema: zodToJsonSchema(UpdateWorkItemSchema), }, + { + name: 'manage_work_item_link', + description: 'Add or remove a link between work items', + inputSchema: zodToJsonSchema(ManageWorkItemLinkSchema), + }, // Repository tools { name: 'get_repository', @@ -222,6 +229,20 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } + case 'manage_work_item_link': { + const args = ManageWorkItemLinkSchema.parse(request.params.arguments); + const result = await manageWorkItemLink(connection, args.projectId, { + sourceWorkItemId: args.sourceWorkItemId, + targetWorkItemId: args.targetWorkItemId, + operation: args.operation, + relationType: args.relationType, + newRelationType: args.newRelationType, + comment: args.comment, + }); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } // Repository tools case 'get_repository': {