Skip to content

Commit 173c822

Browse files
danhellemaaudzei
andauthored
changed tool to allow for bulk create child items instead of one (#241)
Added new tool that allows multiple child work items to be added in a single call. Removed old tool that only allows one at a time. included adding format for Markdown in comments. ## GitHub issue number #227 ## **Associated Risks** No risks ## ✅ **PR Checklist** - [x] **I have read the [contribution guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CONTRIBUTING.md)** - [x] **I have read the [code of conduct guidelines](https://github.com/microsoft/azure-devops-mcp/blob/main/CODE_OF_CONDUCT.md)** - [x] Title of the pull request is clear and informative. - [x] 👌 Code hygiene - [x] 🔭 Telemetry added, updated, or N/A - [x] 📄 Documentation added, updated, or N/A - [x] 🛡️ Automated tests added, or N/A ## 🧪 **How did you test it?** Removed old tests and still need to add tests for new method. But tested this out a lot manually. --------- Co-authored-by: Anton Audzei <antonaudzei@microsoft.com>
1 parent d7cf578 commit 173c822

File tree

3 files changed

+123
-108
lines changed

3 files changed

+123
-108
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Interact with these Azure DevOps services:
6666
- **wit_list_work_item_comments**: Retrieves a list of comments for a work item by ID.
6767
- **wit_get_work_items_for_iteration**: Retrieves a list of work items for a specified iteration.
6868
- **wit_add_work_item_comment**: Add comment to a work item by ID.
69-
- **wit_add_child_work_item**: Create a child work item from a parent by ID.
69+
- **wit_add_child_work_items**: Create one or many child work items of a specific work item type for the given parent Id
7070
- **wit_link_work_item_to_pull_request**: Link a single work item to an existing pull request.
7171
- **wit_get_work_item_type**: Get a specific work item type.
7272
- **wit_get_query**: Get a query by its ID or path.
@@ -75,6 +75,10 @@ Interact with these Azure DevOps services:
7575
- **wit_close_and_link_workitem_duplicates**: Close duplicate work items by id.
7676
- **wit_work_items_link**: Link work items together in batch.
7777

78+
#### Deprecated tools
79+
80+
- **wit_add_child_work_item**: Replaced by `wit_add_child_work_items` so that you can create one or many child items per call.
81+
7882
### 📁 Repositories
7983

8084
- **repo_list_repos_by_project**: Retrieve a list of repositories for a given project.

src/tools/workitems.ts

Lines changed: 118 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const WORKITEM_TOOLS = {
2020
list_work_item_comments: "wit_list_work_item_comments",
2121
get_work_items_for_iteration: "wit_get_work_items_for_iteration",
2222
add_work_item_comment: "wit_add_work_item_comment",
23-
add_child_work_item: "wit_add_child_work_item",
23+
add_child_work_items: "wit_add_child_work_items",
2424
link_work_item_to_pull_request: "wit_link_work_item_to_pull_request",
2525
get_work_item_type: "wit_get_work_item_type",
2626
get_query: "wit_get_query",
@@ -200,63 +200,134 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
200200
);
201201

202202
server.tool(
203-
WORKITEM_TOOLS.add_child_work_item,
204-
"Create a child work item from a parent by ID.",
203+
WORKITEM_TOOLS.add_child_work_items,
204+
"Create one or many child work items from a parent by work item type and parent id.",
205205
{
206206
parentId: z.number().describe("The ID of the parent work item to create a child work item under."),
207207
project: z.string().describe("The name or ID of the Azure DevOps project."),
208208
workItemType: z.string().describe("The type of the child work item to create."),
209-
title: z.string().describe("The title of the child work item."),
210-
description: z.string().describe("The description of the child work item."),
211-
areaPath: z.string().optional().describe("Optional area path for the child work item."),
212-
iterationPath: z.string().optional().describe("Optional iteration path for the child work item."),
209+
items: z.array(
210+
z.object({
211+
title: z.string().describe("The title of the child work item."),
212+
description: z.string().describe("The description of the child work item."),
213+
format: z.enum(["Markdown", "Html"]).default("Html").describe("Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Html'."),
214+
areaPath: z.string().optional().describe("Optional area path for the child work item."),
215+
iterationPath: z.string().optional().describe("Optional iteration path for the child work item."),
216+
})
217+
),
213218
},
214-
async ({ parentId, project, workItemType, title, description, areaPath, iterationPath }) => {
215-
const connection = await connectionProvider();
216-
const workItemApi = await connection.getWorkItemTrackingApi();
217-
218-
const document = [
219-
{
220-
op: "add",
221-
path: "/fields/System.Title",
222-
value: title,
223-
},
224-
{
225-
op: "add",
226-
path: "/fields/System.Description",
227-
value: description,
228-
},
229-
{
230-
op: "add",
231-
path: "/relations/-",
232-
value: {
233-
rel: "System.LinkTypes.Hierarchy-Reverse",
234-
url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${parentId}`,
235-
},
236-
},
237-
];
219+
async ({ parentId, project, workItemType, items }) => {
220+
try {
221+
const connection = await connectionProvider();
222+
const orgUrl = connection.serverUrl;
223+
const accessToken = await tokenProvider();
224+
225+
if (items.length > 50) {
226+
return {
227+
content: [{ type: "text", text: `A maximum of 50 child work items can be created in a single call.` }],
228+
isError: true,
229+
};
230+
}
238231

239-
if (areaPath && areaPath.trim().length > 0) {
240-
document.push({
241-
op: "add",
242-
path: "/fields/System.AreaPath",
243-
value: areaPath,
232+
const body = items.map((item, x) => {
233+
const ops = [
234+
{
235+
op: "add",
236+
path: "/id",
237+
value: `-${x + 1}`,
238+
},
239+
{
240+
op: "add",
241+
path: "/fields/System.Title",
242+
value: item.title,
243+
},
244+
{
245+
op: "add",
246+
path: "/fields/System.Description",
247+
value: item.description,
248+
},
249+
{
250+
op: "add",
251+
path: "/fields/Microsoft.VSTS.TCM.ReproSteps",
252+
value: item.description,
253+
},
254+
{
255+
op: "add",
256+
path: "/relations/-",
257+
value: {
258+
rel: "System.LinkTypes.Hierarchy-Reverse",
259+
url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${parentId}`,
260+
},
261+
},
262+
];
263+
264+
if (item.areaPath && item.areaPath.trim().length > 0) {
265+
ops.push({
266+
op: "add",
267+
path: "/fields/System.AreaPath",
268+
value: item.areaPath,
269+
});
270+
}
271+
272+
if (item.format && item.format === "Markdown") {
273+
ops.push({
274+
op: "add",
275+
path: "/multilineFieldsFormat/System.Description",
276+
value: item.format,
277+
});
278+
279+
ops.push({
280+
op: "add",
281+
path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps",
282+
value: item.format,
283+
});
284+
}
285+
286+
if (item.iterationPath && item.iterationPath.trim().length > 0) {
287+
ops.push({
288+
op: "add",
289+
path: "/fields/System.IterationPath",
290+
value: item.iterationPath,
291+
});
292+
}
293+
294+
return {
295+
method: "PATCH",
296+
uri: `/${project}/_apis/wit/workitems/$${workItemType}?api-version=${batchApiVersion}`,
297+
headers: {
298+
"Content-Type": "application/json-patch+json",
299+
},
300+
body: ops,
301+
};
244302
});
245-
}
246303

247-
if (iterationPath && iterationPath.trim().length > 0) {
248-
document.push({
249-
op: "add",
250-
path: "/fields/System.IterationPath",
251-
value: iterationPath,
304+
const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
305+
method: "PATCH",
306+
headers: {
307+
"Authorization": `Bearer ${accessToken.token}`,
308+
"Content-Type": "application/json",
309+
"User-Agent": userAgentProvider(),
310+
},
311+
body: JSON.stringify(body),
252312
});
253-
}
254313

255-
const childWorkItem = await workItemApi.createWorkItem(null, document, project, workItemType);
314+
if (!response.ok) {
315+
throw new Error(`Failed to update work items in batch: ${response.statusText}`);
316+
}
256317

257-
return {
258-
content: [{ type: "text", text: JSON.stringify(childWorkItem, null, 2) }],
259-
};
318+
const result = await response.json();
319+
320+
return {
321+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
322+
};
323+
} catch (error) {
324+
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
325+
326+
return {
327+
content: [{ type: "text", text: `Error creating child work items: ${errorMessage}` }],
328+
isError: true,
329+
};
330+
}
260331
}
261332
);
262333

test/src/tools/workitems.test.ts

Lines changed: 0 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -367,66 +367,6 @@ describe("configureWorkItemTools", () => {
367367
});
368368
});
369369

370-
describe("add_child_work_item tool", () => {
371-
it("should call workItemApi.add_child_work_item API with the correct parameters and return the expected result", async () => {
372-
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);
373-
374-
const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_item");
375-
376-
if (!call) throw new Error("wit_add_child_work_item tool not registered");
377-
const [, , , handler] = call;
378-
379-
(mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]);
380-
381-
const params = {
382-
parentId: 299,
383-
project: "Contoso",
384-
workItemType: "Task",
385-
title: "Sample task",
386-
description: "This is a sample task",
387-
areaPath: "Contoso\\Development",
388-
iterationPath: "Contoso\\Sprint 1",
389-
};
390-
391-
const document = [
392-
{
393-
op: "add",
394-
path: "/fields/System.Title",
395-
value: params.title,
396-
},
397-
{
398-
op: "add",
399-
path: "/fields/System.Description",
400-
value: params.description,
401-
},
402-
{
403-
op: "add",
404-
path: "/relations/-",
405-
value: {
406-
rel: "System.LinkTypes.Hierarchy-Reverse",
407-
url: `undefined/${params.project}/_apis/wit/workItems/${params.parentId}`,
408-
},
409-
},
410-
{
411-
op: "add",
412-
path: "/fields/System.AreaPath",
413-
value: params.areaPath,
414-
},
415-
{
416-
op: "add",
417-
path: "/fields/System.IterationPath",
418-
value: params.iterationPath,
419-
},
420-
];
421-
422-
const result = await handler(params);
423-
424-
expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledWith(null, document, params.project, params.workItemType);
425-
426-
expect(result.content[0].text).toBe(JSON.stringify([_mockWorkItem], null, 2));
427-
});
428-
});
429-
430370
describe("link_work_item_to_pull_request tool", () => {
431371
it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => {
432372
configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider);

0 commit comments

Comments
 (0)