Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement manage work item link handler #36

Merged
merged 1 commit into from
Mar 31, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

1 change: 1 addition & 0 deletions project-management/task-management/doing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

40 changes: 40 additions & 0 deletions project-management/task-management/done.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/features/work-items/index.ts
Original file line number Diff line number Diff line change
@@ -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';
135 changes: 135 additions & 0 deletions src/features/work-items/manage-work-item-link/feature.spec.int.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
166 changes: 166 additions & 0 deletions src/features/work-items/manage-work-item-link/feature.spec.unit.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading