@mcp-consultant-tools/azure-devops 27.0.0-beta.1 → 27.0.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/AzureDevOpsService.d.ts +62 -0
- package/build/AzureDevOpsService.d.ts.map +1 -1
- package/build/AzureDevOpsService.js +395 -2
- package/build/AzureDevOpsService.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +930 -13
- package/build/index.js.map +1 -1
- package/build/sync/file-utils.d.ts +86 -0
- package/build/sync/file-utils.d.ts.map +1 -0
- package/build/sync/file-utils.js +224 -0
- package/build/sync/file-utils.js.map +1 -0
- package/build/sync/git-utils.d.ts +31 -0
- package/build/sync/git-utils.d.ts.map +1 -0
- package/build/sync/git-utils.js +116 -0
- package/build/sync/git-utils.js.map +1 -0
- package/build/sync/html-converter.d.ts +32 -0
- package/build/sync/html-converter.d.ts.map +1 -0
- package/build/sync/html-converter.js +91 -0
- package/build/sync/html-converter.js.map +1 -0
- package/build/sync/html-detection.d.ts +93 -0
- package/build/sync/html-detection.d.ts.map +1 -0
- package/build/sync/html-detection.js +169 -0
- package/build/sync/html-detection.js.map +1 -0
- package/build/sync/index.d.ts +12 -0
- package/build/sync/index.d.ts.map +1 -0
- package/build/sync/index.js +12 -0
- package/build/sync/index.js.map +1 -0
- package/build/sync/markdown-serializer.d.ts +136 -0
- package/build/sync/markdown-serializer.d.ts.map +1 -0
- package/build/sync/markdown-serializer.js +646 -0
- package/build/sync/markdown-serializer.js.map +1 -0
- package/build/sync/task-serializer.d.ts +93 -0
- package/build/sync/task-serializer.d.ts.map +1 -0
- package/build/sync/task-serializer.js +395 -0
- package/build/sync/task-serializer.js.map +1 -0
- package/build/tool-examples.d.ts +56 -0
- package/build/tool-examples.d.ts.map +1 -0
- package/build/tool-examples.js +142 -0
- package/build/tool-examples.js.map +1 -0
- package/package.json +3 -1
package/build/index.js
CHANGED
|
@@ -10,6 +10,14 @@ import { realpathSync } from "node:fs";
|
|
|
10
10
|
import { createMcpServer, createEnvLoader } from "@mcp-consultant-tools/core";
|
|
11
11
|
import { AzureDevOpsService } from "./AzureDevOpsService.js";
|
|
12
12
|
import { z } from 'zod';
|
|
13
|
+
import { descWithExamples, WIQL_EXAMPLES, PATCH_OP_EXAMPLES, WORK_ITEM_FIELD_EXAMPLES, SYNC_TO_FILE_EXAMPLES, SYNC_FROM_FILE_EXAMPLES, SYNC_TASKS_TO_FILE_EXAMPLES, SYNC_TASKS_FROM_FILE_EXAMPLES, PR_BRANCH_REF_EXAMPLES, PR_MERGE_STRATEGY_EXAMPLES, PR_VOTE_EXAMPLES, } from './tool-examples.js';
|
|
14
|
+
import { checkFieldFormats, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
|
|
15
|
+
// Task sync utilities
|
|
16
|
+
tasksToMarkdown, parseTasksMarkdown, buildTaskPatchOperations, buildNewTaskFields, getTasksFilePath, updateTasksFileAfterCreate,
|
|
17
|
+
// New work item utilities
|
|
18
|
+
parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile,
|
|
19
|
+
// Markdown format utilities
|
|
20
|
+
getAllLargeTextFields, autoConvertFieldsToMarkdown, isHtmlContent, } from './sync/index.js';
|
|
13
21
|
/**
|
|
14
22
|
* Register azure-devops tools and prompts to an MCP server
|
|
15
23
|
* @param server - The MCP server instance
|
|
@@ -316,6 +324,45 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
316
324
|
// ========================================
|
|
317
325
|
// TOOLS
|
|
318
326
|
// ========================================
|
|
327
|
+
server.tool("get-configuration", "Get the configured Azure DevOps organization and projects. Use this to construct correct URLs.", {}, async () => {
|
|
328
|
+
try {
|
|
329
|
+
const organization = process.env.AZUREDEVOPS_ORGANIZATION;
|
|
330
|
+
const projects = process.env.AZUREDEVOPS_PROJECTS?.split(",").map(p => p.trim()).filter(p => p) || [];
|
|
331
|
+
const syncFolder = process.env.AZUREDEVOPS_SYNC_FOLDER || 'docs/user-stories';
|
|
332
|
+
if (!organization || projects.length === 0) {
|
|
333
|
+
return {
|
|
334
|
+
content: [{
|
|
335
|
+
type: "text",
|
|
336
|
+
text: "Azure DevOps not configured. Set AZUREDEVOPS_ORGANIZATION and AZUREDEVOPS_PROJECTS environment variables.",
|
|
337
|
+
}],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const config = {
|
|
341
|
+
organization,
|
|
342
|
+
projects,
|
|
343
|
+
syncFolder,
|
|
344
|
+
urlPatterns: {
|
|
345
|
+
workItem: `https://dev.azure.com/${organization}/{project}/_workitems/edit/{id}`,
|
|
346
|
+
pullRequest: `https://dev.azure.com/${organization}/{project}/_git/{repo}/pullrequest/{id}`,
|
|
347
|
+
wiki: `https://dev.azure.com/${organization}/{project}/_wiki/wikis/{wikiName}/{pagePath}`,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
return {
|
|
351
|
+
content: [{
|
|
352
|
+
type: "text",
|
|
353
|
+
text: `Azure DevOps Configuration:\n\n${JSON.stringify(config, null, 2)}`,
|
|
354
|
+
}],
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
return {
|
|
359
|
+
content: [{
|
|
360
|
+
type: "text",
|
|
361
|
+
text: `Failed to get configuration: ${error.message}`,
|
|
362
|
+
}],
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
});
|
|
319
366
|
server.tool("get-wikis", "Get all wikis in an Azure DevOps project", {
|
|
320
367
|
project: z.string().describe("The project name"),
|
|
321
368
|
}, async ({ project }) => {
|
|
@@ -469,7 +516,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
469
516
|
};
|
|
470
517
|
}
|
|
471
518
|
});
|
|
472
|
-
server.tool("
|
|
519
|
+
server.tool("ado-str-replace-wiki", "Replace a specific string in an Azure DevOps wiki page without rewriting entire content. More efficient than update-wiki-page for small changes. (requires AZUREDEVOPS_ENABLE_WIKI_WRITE=true)", {
|
|
473
520
|
project: z.string().describe("The project name"),
|
|
474
521
|
wikiId: z.string().describe("The wiki identifier (ID or name)"),
|
|
475
522
|
pagePath: z.string().describe("The path to the wiki page (e.g., '/SharePoint-Online/04-DEV-Configuration')"),
|
|
@@ -535,7 +582,7 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
535
582
|
});
|
|
536
583
|
server.tool("query-work-items", "Query work items using WIQL (Work Item Query Language) in Azure DevOps", {
|
|
537
584
|
project: z.string().describe("The project name"),
|
|
538
|
-
wiql: z.string().describe("The WIQL query string
|
|
585
|
+
wiql: z.string().describe(descWithExamples("The WIQL query string. SQL-like syntax with field names in brackets. Common fields: [System.Id], [System.Title], [System.State], [System.WorkItemType], [System.AssignedTo], [System.Parent]. Use @Me for current user.", WIQL_EXAMPLES)),
|
|
539
586
|
maxResults: z.number().optional().describe("Maximum number of results (default: 200)"),
|
|
540
587
|
}, async ({ project, wiql, maxResults }) => {
|
|
541
588
|
try {
|
|
@@ -622,24 +669,53 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
622
669
|
};
|
|
623
670
|
}
|
|
624
671
|
});
|
|
625
|
-
server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
|
|
672
|
+
server.tool("update-work-item", "Update a work item in Azure DevOps using JSON Patch operations (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true). Auto-injects markdown format operations for large text fields.", {
|
|
626
673
|
project: z.string().describe("The project name"),
|
|
627
674
|
workItemId: z.number().describe("The work item ID"),
|
|
628
675
|
patchOperations: z.array(z.object({
|
|
629
|
-
op: z.string().describe("The operation type
|
|
630
|
-
path: z.string().describe("The field path (e.g., '/fields/System.State')"),
|
|
676
|
+
op: z.string().describe("The operation type: 'add' (set value), 'replace' (update existing), or 'remove' (clear field)"),
|
|
677
|
+
path: z.string().describe("The field path starting with '/fields/' (e.g., '/fields/System.State', '/fields/System.Title')"),
|
|
631
678
|
value: z.any().optional().describe("The value to set (not required for 'remove' operation)")
|
|
632
|
-
})).describe("Array of JSON Patch operations"),
|
|
633
|
-
|
|
679
|
+
})).describe(descWithExamples("Array of JSON Patch operations. Each operation specifies what to change.", PATCH_OP_EXAMPLES)),
|
|
680
|
+
skipAutoConvert: z.boolean().optional().describe("Skip automatic markdown format injection for large text fields. Only use when explicitly requested. Default: false"),
|
|
681
|
+
}, async ({ project, workItemId, patchOperations, skipAutoConvert }) => {
|
|
634
682
|
try {
|
|
635
683
|
const service = getAzureDevOpsService();
|
|
636
|
-
|
|
684
|
+
// Auto-inject markdown format operations for large text fields
|
|
685
|
+
let finalOperations = [...patchOperations];
|
|
686
|
+
const formatOpsAdded = [];
|
|
687
|
+
if (!skipAutoConvert) {
|
|
688
|
+
// Find large text fields being updated (includes custom fields)
|
|
689
|
+
const allLargeTextFields = getAllLargeTextFields();
|
|
690
|
+
const largeTextFieldsBeingUpdated = patchOperations
|
|
691
|
+
.filter((op) => op.path?.startsWith('/fields/'))
|
|
692
|
+
.map((op) => op.path.replace('/fields/', ''))
|
|
693
|
+
.filter((field) => allLargeTextFields.includes(field));
|
|
694
|
+
for (const field of largeTextFieldsBeingUpdated) {
|
|
695
|
+
// Check if format operation already exists
|
|
696
|
+
const hasFormatOp = finalOperations.some((op) => op.path === `/multilineFieldsFormat/${field}`);
|
|
697
|
+
if (!hasFormatOp) {
|
|
698
|
+
// Add format operation to ensure markdown
|
|
699
|
+
finalOperations.push({
|
|
700
|
+
op: 'add',
|
|
701
|
+
path: `/multilineFieldsFormat/${field}`,
|
|
702
|
+
value: 'Markdown'
|
|
703
|
+
});
|
|
704
|
+
formatOpsAdded.push(field);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const result = await service.updateWorkItem(project, workItemId, finalOperations);
|
|
709
|
+
let message = `Updated work item ${workItemId}`;
|
|
710
|
+
if (formatOpsAdded.length > 0) {
|
|
711
|
+
message += ` (auto-set markdown format for: ${formatOpsAdded.join(', ')})`;
|
|
712
|
+
}
|
|
637
713
|
const resultStr = JSON.stringify(result, null, 2);
|
|
638
714
|
return {
|
|
639
715
|
content: [
|
|
640
716
|
{
|
|
641
717
|
type: "text",
|
|
642
|
-
text:
|
|
718
|
+
text: `${message}:\n\n${resultStr}`,
|
|
643
719
|
},
|
|
644
720
|
],
|
|
645
721
|
};
|
|
@@ -658,8 +734,8 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
658
734
|
});
|
|
659
735
|
server.tool("create-work-item", "Create a new work item in Azure DevOps with optional parent relationship (requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true)", {
|
|
660
736
|
project: z.string().describe("The project name"),
|
|
661
|
-
workItemType: z.string().describe("The work item type
|
|
662
|
-
fields: z.record(z.any()).describe("Object with field values
|
|
737
|
+
workItemType: z.string().describe("The work item type: 'Bug', 'Task', 'User Story', 'Feature', 'Epic', or custom types"),
|
|
738
|
+
fields: z.record(z.any()).describe(descWithExamples("Object with field values. Required: System.Title. Common fields: System.Description, System.State, System.AssignedTo, Microsoft.VSTS.Common.AcceptanceCriteria, Microsoft.VSTS.TCM.ReproSteps (bugs).", WORK_ITEM_FIELD_EXAMPLES)),
|
|
663
739
|
parentId: z.number().optional().describe("Optional parent work item ID (for creating child items). Simplified alternative to relations parameter."),
|
|
664
740
|
relations: z.array(z.object({
|
|
665
741
|
rel: z.string().describe("Relation type (e.g., 'System.LinkTypes.Hierarchy-Reverse' for parent)"),
|
|
@@ -839,6 +915,120 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
839
915
|
return { content: [{ type: "text", text: `Failed to add pull request thread: ${error.message}` }] };
|
|
840
916
|
}
|
|
841
917
|
});
|
|
918
|
+
server.tool("create-pull-request", "Create a new pull request in a Git repository. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
919
|
+
project: z.string().describe("The project name"),
|
|
920
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
921
|
+
sourceRefName: z.string().describe(descWithExamples("Source branch full ref name", PR_BRANCH_REF_EXAMPLES)),
|
|
922
|
+
targetRefName: z.string().describe(descWithExamples("Target branch full ref name", PR_BRANCH_REF_EXAMPLES)),
|
|
923
|
+
title: z.string().describe("Pull request title"),
|
|
924
|
+
description: z.string().optional().describe("Pull request description (markdown supported)"),
|
|
925
|
+
reviewerIds: z.array(z.string()).optional().describe("Reviewer GUIDs or unique names"),
|
|
926
|
+
isDraft: z.boolean().optional().describe("Create as draft PR (default: false)"),
|
|
927
|
+
}, async ({ project, repositoryId, sourceRefName, targetRefName, title, description, reviewerIds, isDraft }) => {
|
|
928
|
+
try {
|
|
929
|
+
const service = getAzureDevOpsService();
|
|
930
|
+
const result = await service.createPullRequest(project, repositoryId, sourceRefName, targetRefName, title, description, reviewerIds, isDraft);
|
|
931
|
+
return { content: [{ type: "text", text: `Created PR #${result.pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
console.error("Error creating pull request:", error);
|
|
935
|
+
return { content: [{ type: "text", text: `Failed to create pull request: ${error.message}` }] };
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
server.tool("update-pull-request", "Update a pull request's title, description, status, or draft state. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
939
|
+
project: z.string().describe("The project name"),
|
|
940
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
941
|
+
pullRequestId: z.number().describe("The pull request ID"),
|
|
942
|
+
title: z.string().optional().describe("New title"),
|
|
943
|
+
description: z.string().optional().describe("New description"),
|
|
944
|
+
status: z.enum(["abandoned", "active"]).optional().describe("Set PR status (abandoned or active)"),
|
|
945
|
+
isDraft: z.boolean().optional().describe("Set draft state"),
|
|
946
|
+
}, async ({ project, repositoryId, pullRequestId, title, description, status, isDraft }) => {
|
|
947
|
+
try {
|
|
948
|
+
const service = getAzureDevOpsService();
|
|
949
|
+
const result = await service.updatePullRequest(project, repositoryId, pullRequestId, { title, description, status, isDraft });
|
|
950
|
+
return { content: [{ type: "text", text: `Updated PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
console.error("Error updating pull request:", error);
|
|
954
|
+
return { content: [{ type: "text", text: `Failed to update pull request: ${error.message}` }] };
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
server.tool("complete-pull-request", "Complete (merge) a pull request with configurable merge strategy. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
958
|
+
project: z.string().describe("The project name"),
|
|
959
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
960
|
+
pullRequestId: z.number().describe("The pull request ID"),
|
|
961
|
+
mergeStrategy: z.enum(["squash", "noFastForward", "rebase", "rebaseMerge"]).optional()
|
|
962
|
+
.describe(descWithExamples("Merge strategy (default: squash)", PR_MERGE_STRATEGY_EXAMPLES)),
|
|
963
|
+
deleteSourceBranch: z.boolean().optional().describe("Delete source branch after merge (default: true)"),
|
|
964
|
+
transitionWorkItems: z.boolean().optional().describe("Transition linked work items (default: true)"),
|
|
965
|
+
mergeCommitMessage: z.string().optional().describe("Custom merge commit message"),
|
|
966
|
+
}, async ({ project, repositoryId, pullRequestId, mergeStrategy, deleteSourceBranch, transitionWorkItems, mergeCommitMessage }) => {
|
|
967
|
+
try {
|
|
968
|
+
const service = getAzureDevOpsService();
|
|
969
|
+
const result = await service.completePullRequest(project, repositoryId, pullRequestId, mergeStrategy || 'squash', deleteSourceBranch !== undefined ? deleteSourceBranch : true, transitionWorkItems !== undefined ? transitionWorkItems : true, mergeCommitMessage);
|
|
970
|
+
return { content: [{ type: "text", text: `Completed PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
console.error("Error completing pull request:", error);
|
|
974
|
+
return { content: [{ type: "text", text: `Failed to complete pull request: ${error.message}` }] };
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
server.tool("add-pr-reviewer", "Add or remove a reviewer from a pull request. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
978
|
+
project: z.string().describe("The project name"),
|
|
979
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
980
|
+
pullRequestId: z.number().describe("The pull request ID"),
|
|
981
|
+
reviewerId: z.string().describe("Reviewer GUID or unique name"),
|
|
982
|
+
isRequired: z.boolean().optional().describe("Whether the reviewer is required (default: false)"),
|
|
983
|
+
remove: z.boolean().optional().describe("Set to true to remove the reviewer instead of adding"),
|
|
984
|
+
}, async ({ project, repositoryId, pullRequestId, reviewerId, isRequired, remove }) => {
|
|
985
|
+
try {
|
|
986
|
+
const service = getAzureDevOpsService();
|
|
987
|
+
const result = await service.addOrRemovePrReviewer(project, repositoryId, pullRequestId, reviewerId, isRequired, remove);
|
|
988
|
+
return { content: [{ type: "text", text: `${remove ? 'Removed' : 'Added'} reviewer on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
console.error("Error managing PR reviewer:", error);
|
|
992
|
+
return { content: [{ type: "text", text: `Failed to manage PR reviewer: ${error.message}` }] };
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
server.tool("vote-pull-request", "Submit a vote (approve, reject, etc.) on a pull request. Defaults to authenticated user. (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
996
|
+
project: z.string().describe("The project name"),
|
|
997
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
998
|
+
pullRequestId: z.number().describe("The pull request ID"),
|
|
999
|
+
vote: z.enum(["approve", "approveWithSuggestions", "noResponse", "waitForAuthor", "reject"])
|
|
1000
|
+
.describe(descWithExamples("Vote to submit", PR_VOTE_EXAMPLES)),
|
|
1001
|
+
reviewerId: z.string().optional().describe("Reviewer GUID (defaults to authenticated user)"),
|
|
1002
|
+
}, async ({ project, repositoryId, pullRequestId, vote, reviewerId }) => {
|
|
1003
|
+
try {
|
|
1004
|
+
const service = getAzureDevOpsService();
|
|
1005
|
+
const result = await service.votePullRequest(project, repositoryId, pullRequestId, vote, reviewerId);
|
|
1006
|
+
return { content: [{ type: "text", text: `Voted '${vote}' on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1007
|
+
}
|
|
1008
|
+
catch (error) {
|
|
1009
|
+
console.error("Error voting on pull request:", error);
|
|
1010
|
+
return { content: [{ type: "text", text: `Failed to vote on pull request: ${error.message}` }] };
|
|
1011
|
+
}
|
|
1012
|
+
});
|
|
1013
|
+
server.tool("reply-to-pr-thread", "Reply to a pull request comment thread and/or update thread status (e.g., resolve). (requires AZUREDEVOPS_ENABLE_PR_WRITE=true)", {
|
|
1014
|
+
project: z.string().describe("The project name"),
|
|
1015
|
+
repositoryId: z.string().describe("Repository ID (GUID) or name"),
|
|
1016
|
+
pullRequestId: z.number().describe("The pull request ID"),
|
|
1017
|
+
threadId: z.number().describe("The thread ID to reply to"),
|
|
1018
|
+
content: z.string().optional().describe("Reply text (markdown supported)"),
|
|
1019
|
+
status: z.enum(["active", "fixed", "wontFix", "closed", "byDesign", "pending"]).optional()
|
|
1020
|
+
.describe("Update thread status (e.g., 'fixed' to resolve)"),
|
|
1021
|
+
}, async ({ project, repositoryId, pullRequestId, threadId, content, status }) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const service = getAzureDevOpsService();
|
|
1024
|
+
const result = await service.replyToPrThread(project, repositoryId, pullRequestId, threadId, content, status);
|
|
1025
|
+
return { content: [{ type: "text", text: `Reply to thread #${threadId} on PR #${pullRequestId}:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
console.error("Error replying to PR thread:", error);
|
|
1029
|
+
return { content: [{ type: "text", text: `Failed to reply to PR thread: ${error.message}` }] };
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
842
1032
|
}
|
|
843
1033
|
// ========================================
|
|
844
1034
|
// VARIABLE GROUP TOOLS
|
|
@@ -900,9 +1090,736 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
900
1090
|
};
|
|
901
1091
|
}
|
|
902
1092
|
});
|
|
1093
|
+
// ========================================
|
|
1094
|
+
// BUILD TROUBLESHOOTING TOOLS (Read-only)
|
|
1095
|
+
// NOTE: These tools are duplicated in azure-devops-admin package.
|
|
1096
|
+
// If you update these, also update packages/azure-devops-admin/src/index.ts
|
|
1097
|
+
// ========================================
|
|
1098
|
+
server.tool("get-build-status", "Get build status and details. Use detail='summary' for basic status, 'timeline' for step breakdown (default scope='problems' shows only failed/warning items), or 'full' for logs. The timelineScope controls what records are included: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything).", {
|
|
1099
|
+
project: z.string().describe("The project name"),
|
|
1100
|
+
buildId: z.number().describe("The build ID"),
|
|
1101
|
+
detail: z.enum(["summary", "timeline", "full"]).optional().describe("Level of detail: 'summary' (default), 'timeline' (include steps), or 'full' (include logs)"),
|
|
1102
|
+
timelineScope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Timeline scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
|
|
1103
|
+
maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
|
|
1104
|
+
}, async ({ project, buildId, detail, timelineScope, maxIssues }) => {
|
|
1105
|
+
try {
|
|
1106
|
+
const service = getAzureDevOpsService();
|
|
1107
|
+
const result = await service.getBuildStatus(project, buildId, detail || 'summary', timelineScope || 'problems', maxIssues || 5);
|
|
1108
|
+
return { content: [{ type: "text", text: `Build ${buildId} status:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1109
|
+
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
console.error("Error getting build status:", error);
|
|
1112
|
+
return { content: [{ type: "text", text: `Failed to get build status: ${error.message}` }] };
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
server.tool("get-build-timeline", "Get step-by-step breakdown of a build. Shows stages, jobs, and tasks with timing, status, and error/warning counts. Use scope to control output size: 'problems' (default, only errors/warnings), 'stages' (minimal), 'jobs' (moderate), 'all' (everything). Always includes summary stats regardless of scope.", {
|
|
1116
|
+
project: z.string().describe("The project name"),
|
|
1117
|
+
buildId: z.number().describe("The build ID"),
|
|
1118
|
+
scope: z.enum(["stages", "jobs", "all", "problems"]).optional().describe("Filter scope: 'problems' (default, only errors/warnings/failures), 'stages' (minimal), 'jobs' (moderate), 'all' (everything - may be large)"),
|
|
1119
|
+
maxIssues: z.number().optional().describe("Maximum issues per record (default: 5, prioritizes errors over warnings)"),
|
|
1120
|
+
}, async ({ project, buildId, scope, maxIssues }) => {
|
|
1121
|
+
try {
|
|
1122
|
+
const service = getAzureDevOpsService();
|
|
1123
|
+
const result = await service.getBuildTimeline(project, buildId, scope || 'problems', maxIssues || 5);
|
|
1124
|
+
return { content: [{ type: "text", text: `Build ${buildId} timeline:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1125
|
+
}
|
|
1126
|
+
catch (error) {
|
|
1127
|
+
console.error("Error getting build timeline:", error);
|
|
1128
|
+
return { content: [{ type: "text", text: `Failed to get build timeline: ${error.message}` }] };
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
server.tool("get-build-logs", "Get build logs. Without logId, returns list of available logs with line counts. With logId, returns that log's content. NOTE: For verbose logs, consider using a sub-agent to analyze the content.", {
|
|
1132
|
+
project: z.string().describe("The project name"),
|
|
1133
|
+
buildId: z.number().describe("The build ID"),
|
|
1134
|
+
logId: z.number().optional().describe("Optional specific log ID to retrieve content"),
|
|
1135
|
+
}, async ({ project, buildId, logId }) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const service = getAzureDevOpsService();
|
|
1138
|
+
const result = await service.getBuildLogs(project, buildId, logId);
|
|
1139
|
+
return { content: [{ type: "text", text: `Build ${buildId} logs:\n\n${JSON.stringify(result, null, 2)}` }] };
|
|
1140
|
+
}
|
|
1141
|
+
catch (error) {
|
|
1142
|
+
console.error("Error getting build logs:", error);
|
|
1143
|
+
return { content: [{ type: "text", text: `Failed to get build logs: ${error.message}` }] };
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
// ========================================
|
|
1147
|
+
// WORK ITEM SYNC TOOLS (Local Markdown)
|
|
1148
|
+
// ========================================
|
|
1149
|
+
server.tool("sync-work-item-to-file", descWithExamples("Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Auto-converts HTML fields to markdown. Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", SYNC_TO_FILE_EXAMPLES), {
|
|
1150
|
+
project: z.string().describe("The project name"),
|
|
1151
|
+
workItemIds: z.array(z.number()).default([]).describe("Work item IDs to pull (optional if using parentId)"),
|
|
1152
|
+
parentId: z.number().optional().describe("Pull all child work items of this parent (e.g., Feature ID to pull all User Stories)"),
|
|
1153
|
+
childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story'). Common values: 'User Story', 'Bug', 'Task'"),
|
|
1154
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1155
|
+
includeComments: z.boolean().optional().describe("Also save comments to {id}-comments.md (default: false)"),
|
|
1156
|
+
skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
|
|
1157
|
+
}, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments, skipAutoConvert }) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const service = getAzureDevOpsService();
|
|
1160
|
+
const syncConfig = getSyncConfig(folder);
|
|
1161
|
+
// Validate folder path for security
|
|
1162
|
+
validateFolderPath(syncConfig.folder);
|
|
1163
|
+
// Determine which work items to pull
|
|
1164
|
+
let workItemIds = providedWorkItemIds || [];
|
|
1165
|
+
// If parentId is provided, query for child work items
|
|
1166
|
+
if (parentId) {
|
|
1167
|
+
const type = childType || 'User Story';
|
|
1168
|
+
const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = '${type}' ORDER BY [System.Id] ASC`;
|
|
1169
|
+
const queryResult = await service.queryWorkItems(project, wiql, 200);
|
|
1170
|
+
if (queryResult.workItems && queryResult.workItems.length > 0) {
|
|
1171
|
+
const childIds = queryResult.workItems.map((wi) => wi.id);
|
|
1172
|
+
workItemIds = [...workItemIds, ...childIds];
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
// Validate that we have work items to pull
|
|
1176
|
+
if (workItemIds.length === 0) {
|
|
1177
|
+
return {
|
|
1178
|
+
content: [{
|
|
1179
|
+
type: "text",
|
|
1180
|
+
text: parentId
|
|
1181
|
+
? `No ${childType || 'User Story'} work items found under parent #${parentId}`
|
|
1182
|
+
: "No work item IDs provided. Specify workItemIds or parentId.",
|
|
1183
|
+
}],
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
// Remove duplicates
|
|
1187
|
+
workItemIds = [...new Set(workItemIds)];
|
|
1188
|
+
const pulled = [];
|
|
1189
|
+
const skipped = [];
|
|
1190
|
+
const commentsFiles = [];
|
|
1191
|
+
const filesToCommit = [];
|
|
1192
|
+
await ensureFolderExists(syncConfig.folder);
|
|
1193
|
+
for (const workItemId of workItemIds) {
|
|
1194
|
+
try {
|
|
1195
|
+
// Fetch work item from ADO
|
|
1196
|
+
let workItem = await service.getWorkItem(project, workItemId);
|
|
1197
|
+
let revision = workItem.rev || workItem._rev || 1;
|
|
1198
|
+
let convertedFields = [];
|
|
1199
|
+
// Check field formats
|
|
1200
|
+
const formats = checkFieldFormats(workItem);
|
|
1201
|
+
if (!formats.ready) {
|
|
1202
|
+
if (skipAutoConvert) {
|
|
1203
|
+
// User explicitly requested to skip conversion
|
|
1204
|
+
const htmlFields = [];
|
|
1205
|
+
if (formats.description === 'html')
|
|
1206
|
+
htmlFields.push('Description');
|
|
1207
|
+
if (formats.acceptanceCriteria === 'html')
|
|
1208
|
+
htmlFields.push('Acceptance Criteria');
|
|
1209
|
+
skipped.push({
|
|
1210
|
+
id: workItemId,
|
|
1211
|
+
reason: `HTML fields: ${htmlFields.join(', ')}. skipAutoConvert=true, skipping.`,
|
|
1212
|
+
});
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
// Auto-convert HTML fields to markdown in ADO
|
|
1216
|
+
const fieldsToConvert = [];
|
|
1217
|
+
if (formats.description === 'html')
|
|
1218
|
+
fieldsToConvert.push('System.Description');
|
|
1219
|
+
if (formats.acceptanceCriteria === 'html')
|
|
1220
|
+
fieldsToConvert.push('Microsoft.VSTS.Common.AcceptanceCriteria');
|
|
1221
|
+
try {
|
|
1222
|
+
convertedFields = await autoConvertFieldsToMarkdown(service, project, workItemId, workItem.fields, fieldsToConvert);
|
|
1223
|
+
// Re-fetch work item with converted content
|
|
1224
|
+
workItem = await service.getWorkItem(project, workItemId);
|
|
1225
|
+
revision = workItem.rev || workItem._rev || 1;
|
|
1226
|
+
}
|
|
1227
|
+
catch (convertError) {
|
|
1228
|
+
skipped.push({
|
|
1229
|
+
id: workItemId,
|
|
1230
|
+
reason: `Failed to auto-convert HTML: ${convertError.message}`,
|
|
1231
|
+
});
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// Convert to markdown and save
|
|
1236
|
+
const { content: markdown, skippedFields: secondarySkipped } = workItemToMarkdown(workItem, revision);
|
|
1237
|
+
const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
|
|
1238
|
+
await writeWorkItemFile(filePath, markdown);
|
|
1239
|
+
pulled.push({
|
|
1240
|
+
id: workItemId,
|
|
1241
|
+
file: filePath,
|
|
1242
|
+
revision,
|
|
1243
|
+
...(convertedFields.length > 0 ? { converted: convertedFields } : {}),
|
|
1244
|
+
...(secondarySkipped.length > 0 ? { skippedFields: secondarySkipped } : {}),
|
|
1245
|
+
});
|
|
1246
|
+
filesToCommit.push({ filePath, workItemId });
|
|
1247
|
+
// Optionally save comments
|
|
1248
|
+
if (includeComments) {
|
|
1249
|
+
const comments = await service.getWorkItemComments(project, workItemId);
|
|
1250
|
+
const commentsMarkdown = commentsToMarkdown(workItem, comments.comments || []);
|
|
1251
|
+
const commentsPath = getCommentsFilePath(syncConfig.folder, workItemId);
|
|
1252
|
+
await writeWorkItemFile(commentsPath, commentsMarkdown);
|
|
1253
|
+
commentsFiles.push({
|
|
1254
|
+
id: workItemId,
|
|
1255
|
+
file: commentsPath,
|
|
1256
|
+
count: (comments.comments || []).length,
|
|
1257
|
+
});
|
|
1258
|
+
filesToCommit.push({ filePath: commentsPath, workItemId });
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
catch (error) {
|
|
1262
|
+
skipped.push({ id: workItemId, reason: error.message });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Auto-commit if enabled
|
|
1266
|
+
let committed = false;
|
|
1267
|
+
if (syncConfig.autoCommit && filesToCommit.length > 0) {
|
|
1268
|
+
const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced');
|
|
1269
|
+
committed = commitResult.committed;
|
|
1270
|
+
}
|
|
1271
|
+
const result = {
|
|
1272
|
+
pulled,
|
|
1273
|
+
skipped,
|
|
1274
|
+
...(includeComments ? { commentsFiles } : {}),
|
|
1275
|
+
folder: syncConfig.folder,
|
|
1276
|
+
committed,
|
|
1277
|
+
};
|
|
1278
|
+
return {
|
|
1279
|
+
content: [{
|
|
1280
|
+
type: "text",
|
|
1281
|
+
text: `Synced ${pulled.length} work item(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1282
|
+
}],
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
console.error("Error syncing work items to files:", error);
|
|
1287
|
+
return {
|
|
1288
|
+
content: [{
|
|
1289
|
+
type: "text",
|
|
1290
|
+
text: `Failed to sync work items: ${error.message}`,
|
|
1291
|
+
}],
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
server.tool("sync-work-item-from-file", descWithExamples("Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_FROM_FILE_EXAMPLES), {
|
|
1296
|
+
project: z.string().describe("The project name"),
|
|
1297
|
+
workItemIds: z.array(z.number()).default([]).describe("Work item IDs to push (optional - new_*.md files are auto-detected)"),
|
|
1298
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1299
|
+
skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion. Only use when explicitly requested. Default: false (auto-convert enabled)"),
|
|
1300
|
+
}, async ({ project, workItemIds, folder, skipAutoConvert }) => {
|
|
1301
|
+
try {
|
|
1302
|
+
const service = getAzureDevOpsService();
|
|
1303
|
+
const syncConfig = getSyncConfig(folder);
|
|
1304
|
+
// Validate folder path for security
|
|
1305
|
+
validateFolderPath(syncConfig.folder);
|
|
1306
|
+
const pushed = [];
|
|
1307
|
+
const partial = [];
|
|
1308
|
+
const created = [];
|
|
1309
|
+
const failed = [];
|
|
1310
|
+
// Step 1: Auto-detect and process new_*.md files
|
|
1311
|
+
const newFiles = await findNewWorkItemFiles(syncConfig.folder);
|
|
1312
|
+
for (const filePath of newFiles) {
|
|
1313
|
+
try {
|
|
1314
|
+
// Parse the new work item file
|
|
1315
|
+
const content = await readFileContent(filePath);
|
|
1316
|
+
const parsed = parseNewWorkItemMarkdown(content);
|
|
1317
|
+
// Fetch parent work item to inherit area/iteration paths
|
|
1318
|
+
const parentWorkItem = await service.getWorkItem(project, parsed.frontmatter.parent);
|
|
1319
|
+
// Build fields for creation
|
|
1320
|
+
const fields = buildNewWorkItemFields(parsed, parentWorkItem);
|
|
1321
|
+
// Create the work item in ADO with parent link
|
|
1322
|
+
const createdWorkItem = await service.createWorkItem(project, parsed.frontmatter.type || 'User Story', fields, parsed.frontmatter.parent);
|
|
1323
|
+
const newId = createdWorkItem.id;
|
|
1324
|
+
const revision = createdWorkItem.rev || createdWorkItem._rev || 1;
|
|
1325
|
+
const url = createdWorkItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${newId}`;
|
|
1326
|
+
// Convert the file content to synced format
|
|
1327
|
+
const syncedContent = convertNewFileToSynced(content, newId, revision, url);
|
|
1328
|
+
// Rename file from new_*.md to {id}.md
|
|
1329
|
+
const newFilePath = getWorkItemFilePath(syncConfig.folder, newId);
|
|
1330
|
+
await writeWorkItemFile(newFilePath, syncedContent);
|
|
1331
|
+
await renameFile(filePath, filePath + '.created'); // Mark original as processed
|
|
1332
|
+
// Actually delete the .created file (cleanup)
|
|
1333
|
+
try {
|
|
1334
|
+
const fs = await import('node:fs/promises');
|
|
1335
|
+
await fs.unlink(filePath + '.created');
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
// Ignore cleanup errors
|
|
1339
|
+
}
|
|
1340
|
+
created.push({
|
|
1341
|
+
id: newId,
|
|
1342
|
+
oldFile: filePath,
|
|
1343
|
+
newFile: newFilePath,
|
|
1344
|
+
parentId: parsed.frontmatter.parent,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
catch (error) {
|
|
1348
|
+
failed.push({ file: filePath, error: error.message });
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
// Step 2: Process existing work items (if workItemIds provided)
|
|
1352
|
+
const idsToProcess = workItemIds || [];
|
|
1353
|
+
for (const workItemId of idsToProcess) {
|
|
1354
|
+
const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
|
|
1355
|
+
try {
|
|
1356
|
+
// Check if file exists
|
|
1357
|
+
if (!await fileExists(filePath)) {
|
|
1358
|
+
failed.push({ id: workItemId, error: `File not found: ${filePath}` });
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
// Parse local file
|
|
1362
|
+
const parsed = await readWorkItemFile(filePath);
|
|
1363
|
+
const oldRevision = parsed.frontmatter.lastSyncedRevision;
|
|
1364
|
+
// Fetch current work item from ADO to compare
|
|
1365
|
+
const currentWorkItem = await service.getWorkItem(project, workItemId);
|
|
1366
|
+
// Build patch operations (auto-converts HTML fields unless skipAutoConvert)
|
|
1367
|
+
const { operations, skippedFields, convertedFields } = buildPatchOperations(parsed, currentWorkItem, skipAutoConvert);
|
|
1368
|
+
if (operations.length === 0 && skippedFields.length === 0) {
|
|
1369
|
+
// No changes
|
|
1370
|
+
pushed.push({
|
|
1371
|
+
id: workItemId,
|
|
1372
|
+
oldRevision,
|
|
1373
|
+
newRevision: currentWorkItem.rev || currentWorkItem._rev || oldRevision,
|
|
1374
|
+
fieldsUpdated: [],
|
|
1375
|
+
});
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
// Update ADO if there are operations
|
|
1379
|
+
let newRevision = oldRevision;
|
|
1380
|
+
let fieldsUpdated = [];
|
|
1381
|
+
if (operations.length > 0) {
|
|
1382
|
+
const updatedWorkItem = await service.updateWorkItem(project, workItemId, operations);
|
|
1383
|
+
newRevision = updatedWorkItem.rev || updatedWorkItem._rev || oldRevision + 1;
|
|
1384
|
+
// Filter out format operations from fieldsUpdated
|
|
1385
|
+
fieldsUpdated = operations
|
|
1386
|
+
.filter(op => op.path.startsWith('/fields/'))
|
|
1387
|
+
.map(op => op.path.replace('/fields/', ''));
|
|
1388
|
+
// Update local file with new revision
|
|
1389
|
+
const content = await readFileContent(filePath);
|
|
1390
|
+
const updatedContent = updateSyncRevision(content, newRevision);
|
|
1391
|
+
await writeWorkItemFile(filePath, updatedContent);
|
|
1392
|
+
}
|
|
1393
|
+
if (skippedFields.length > 0) {
|
|
1394
|
+
partial.push({
|
|
1395
|
+
id: workItemId,
|
|
1396
|
+
oldRevision,
|
|
1397
|
+
newRevision,
|
|
1398
|
+
fieldsUpdated,
|
|
1399
|
+
skippedFields,
|
|
1400
|
+
...(convertedFields.length > 0 ? { convertedFields } : {}),
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
pushed.push({
|
|
1405
|
+
id: workItemId,
|
|
1406
|
+
oldRevision,
|
|
1407
|
+
newRevision,
|
|
1408
|
+
fieldsUpdated,
|
|
1409
|
+
...(convertedFields.length > 0 ? { convertedFields } : {}),
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
failed.push({ id: workItemId, error: error.message });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const result = {
|
|
1418
|
+
created,
|
|
1419
|
+
pushed,
|
|
1420
|
+
partial,
|
|
1421
|
+
failed,
|
|
1422
|
+
folder: syncConfig.folder,
|
|
1423
|
+
};
|
|
1424
|
+
const summary = [];
|
|
1425
|
+
if (created.length > 0)
|
|
1426
|
+
summary.push(`Created ${created.length} new work item(s)`);
|
|
1427
|
+
if (pushed.length > 0)
|
|
1428
|
+
summary.push(`Updated ${pushed.length} work item(s)`);
|
|
1429
|
+
if (partial.length > 0)
|
|
1430
|
+
summary.push(`Partially updated ${partial.length} work item(s)`);
|
|
1431
|
+
if (failed.length > 0)
|
|
1432
|
+
summary.push(`Failed: ${failed.length}`);
|
|
1433
|
+
return {
|
|
1434
|
+
content: [{
|
|
1435
|
+
type: "text",
|
|
1436
|
+
text: `${summary.join(', ')}:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1437
|
+
}],
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
console.error("Error syncing work items from files:", error);
|
|
1442
|
+
return {
|
|
1443
|
+
content: [{
|
|
1444
|
+
type: "text",
|
|
1445
|
+
text: `Failed to push work items: ${error.message}`,
|
|
1446
|
+
}],
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
server.tool("check-work-item-markdown", "Check if work item fields are markdown (required for sync) or HTML format.", {
|
|
1451
|
+
project: z.string().describe("The project name"),
|
|
1452
|
+
workItemIds: z.array(z.number()).describe("Work item IDs to check"),
|
|
1453
|
+
}, async ({ project, workItemIds }) => {
|
|
1454
|
+
try {
|
|
1455
|
+
const service = getAzureDevOpsService();
|
|
1456
|
+
const results = [];
|
|
1457
|
+
let readyCount = 0;
|
|
1458
|
+
let needsConversionCount = 0;
|
|
1459
|
+
for (const workItemId of workItemIds) {
|
|
1460
|
+
try {
|
|
1461
|
+
const workItem = await service.getWorkItem(project, workItemId);
|
|
1462
|
+
const formats = checkFieldFormats(workItem);
|
|
1463
|
+
results.push({
|
|
1464
|
+
id: workItemId,
|
|
1465
|
+
description: formats.description,
|
|
1466
|
+
acceptanceCriteria: formats.acceptanceCriteria,
|
|
1467
|
+
howToTest: formats.additionalFields.howToTest,
|
|
1468
|
+
predeploymentSteps: formats.additionalFields.predeploymentSteps,
|
|
1469
|
+
postdeploymentSteps: formats.additionalFields.postdeploymentSteps,
|
|
1470
|
+
deploymentInformation: formats.additionalFields.deploymentInformation,
|
|
1471
|
+
ready: formats.ready,
|
|
1472
|
+
...(formats.warnings.length > 0 ? { warnings: formats.warnings } : {}),
|
|
1473
|
+
});
|
|
1474
|
+
if (formats.ready) {
|
|
1475
|
+
readyCount++;
|
|
1476
|
+
}
|
|
1477
|
+
else {
|
|
1478
|
+
needsConversionCount++;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
results.push({
|
|
1483
|
+
id: workItemId,
|
|
1484
|
+
description: 'error',
|
|
1485
|
+
acceptanceCriteria: 'error',
|
|
1486
|
+
ready: false,
|
|
1487
|
+
error: error.message || String(error),
|
|
1488
|
+
});
|
|
1489
|
+
needsConversionCount++;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
const result = {
|
|
1493
|
+
results,
|
|
1494
|
+
summary: {
|
|
1495
|
+
ready: readyCount,
|
|
1496
|
+
needsConversion: needsConversionCount,
|
|
1497
|
+
},
|
|
1498
|
+
autoConvertAvailable: true,
|
|
1499
|
+
message: needsConversionCount === 0
|
|
1500
|
+
? 'All work items are markdown format - ready to sync'
|
|
1501
|
+
: `${needsConversionCount} work item(s) have HTML fields. Will be auto-converted to markdown on sync (unless skipAutoConvert=true).`,
|
|
1502
|
+
};
|
|
1503
|
+
return {
|
|
1504
|
+
content: [{
|
|
1505
|
+
type: "text",
|
|
1506
|
+
text: `Work item format check:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1507
|
+
}],
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
catch (error) {
|
|
1511
|
+
console.error("Error checking work item formats:", error);
|
|
1512
|
+
return {
|
|
1513
|
+
content: [{
|
|
1514
|
+
type: "text",
|
|
1515
|
+
text: `Failed to check work item formats: ${error.message}`,
|
|
1516
|
+
}],
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
server.tool("list-synced-work-items", "List work items that have been synced to local markdown files.", {
|
|
1521
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1522
|
+
}, async ({ folder }) => {
|
|
1523
|
+
try {
|
|
1524
|
+
const syncConfig = getSyncConfig(folder);
|
|
1525
|
+
// Validate folder path for security
|
|
1526
|
+
validateFolderPath(syncConfig.folder);
|
|
1527
|
+
const workItems = await listSyncedWorkItems(syncConfig.folder);
|
|
1528
|
+
const result = {
|
|
1529
|
+
workItems,
|
|
1530
|
+
folder: syncConfig.folder,
|
|
1531
|
+
count: workItems.length,
|
|
1532
|
+
};
|
|
1533
|
+
return {
|
|
1534
|
+
content: [{
|
|
1535
|
+
type: "text",
|
|
1536
|
+
text: `Synced work items in ${syncConfig.folder}:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1537
|
+
}],
|
|
1538
|
+
};
|
|
1539
|
+
}
|
|
1540
|
+
catch (error) {
|
|
1541
|
+
console.error("Error listing synced work items:", error);
|
|
1542
|
+
return {
|
|
1543
|
+
content: [{
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: `Failed to list synced work items: ${error.message}`,
|
|
1546
|
+
}],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
1550
|
+
server.tool("create-user-story-file", "Create a new user story template file locally. The file can be edited and then pushed to ADO using sync-work-item-from-file.", {
|
|
1551
|
+
project: z.string().describe("The project name"),
|
|
1552
|
+
parentId: z.number().describe("Parent Feature ID - the new user story will be created under this feature"),
|
|
1553
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1554
|
+
}, async ({ project, parentId, folder }) => {
|
|
1555
|
+
try {
|
|
1556
|
+
const service = getAzureDevOpsService();
|
|
1557
|
+
const syncConfig = getSyncConfig(folder);
|
|
1558
|
+
// Validate folder path for security
|
|
1559
|
+
validateFolderPath(syncConfig.folder);
|
|
1560
|
+
// Fetch parent work item to get title
|
|
1561
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1562
|
+
const parentTitle = parentWorkItem.fields?.['System.Title'] || '';
|
|
1563
|
+
// Find next available index for new file
|
|
1564
|
+
const nextIndex = await findNextNewFileIndex(syncConfig.folder, parentId);
|
|
1565
|
+
const filePath = getNewWorkItemFilePath(syncConfig.folder, parentId, nextIndex);
|
|
1566
|
+
// Generate template content
|
|
1567
|
+
const template = generateNewWorkItemTemplate(parentId, parentTitle, project, 'User Story');
|
|
1568
|
+
// Ensure folder exists and write file
|
|
1569
|
+
await ensureFolderExists(syncConfig.folder);
|
|
1570
|
+
await writeWorkItemFile(filePath, template);
|
|
1571
|
+
const result = {
|
|
1572
|
+
file: filePath,
|
|
1573
|
+
parentId,
|
|
1574
|
+
parentTitle,
|
|
1575
|
+
instructions: [
|
|
1576
|
+
`1. Edit the file to update title, description, and acceptance criteria`,
|
|
1577
|
+
`2. Run sync-work-item-from-file(project: "${project}") to create in ADO`,
|
|
1578
|
+
`3. The file will be renamed to {newId}.md after creation`,
|
|
1579
|
+
],
|
|
1580
|
+
};
|
|
1581
|
+
return {
|
|
1582
|
+
content: [{
|
|
1583
|
+
type: "text",
|
|
1584
|
+
text: `Created new user story template:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1585
|
+
}],
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
console.error("Error creating user story file:", error);
|
|
1590
|
+
return {
|
|
1591
|
+
content: [{
|
|
1592
|
+
type: "text",
|
|
1593
|
+
text: `Failed to create user story file: ${error.message}`,
|
|
1594
|
+
}],
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
// ========================================
|
|
1599
|
+
// TASK SYNC TOOLS
|
|
1600
|
+
// ========================================
|
|
1601
|
+
server.tool("sync-tasks-to-file", descWithExamples("Download all tasks under a parent work item (User Story) to a local markdown file. Auto-converts HTML descriptions to markdown. Supports pulling tasks for multiple parents at once.", SYNC_TASKS_TO_FILE_EXAMPLES), {
|
|
1602
|
+
project: z.string().describe("The project name"),
|
|
1603
|
+
parentIds: z.array(z.number()).describe("Parent work item IDs (User Stories) to fetch tasks for"),
|
|
1604
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1605
|
+
skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
|
|
1606
|
+
}, async ({ project, parentIds, folder, skipAutoConvert }) => {
|
|
1607
|
+
try {
|
|
1608
|
+
const service = getAzureDevOpsService();
|
|
1609
|
+
const syncConfig = getSyncConfig(folder);
|
|
1610
|
+
// Validate folder path for security
|
|
1611
|
+
validateFolderPath(syncConfig.folder);
|
|
1612
|
+
const pulled = [];
|
|
1613
|
+
const failed = [];
|
|
1614
|
+
const filesToCommit = [];
|
|
1615
|
+
await ensureFolderExists(syncConfig.folder);
|
|
1616
|
+
for (const parentId of parentIds) {
|
|
1617
|
+
try {
|
|
1618
|
+
// Fetch parent work item to get title and validate existence
|
|
1619
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1620
|
+
// Query for child tasks using WIQL
|
|
1621
|
+
const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = 'Task' ORDER BY [System.Id] ASC`;
|
|
1622
|
+
const queryResult = await service.queryWorkItems(project, wiql, 200);
|
|
1623
|
+
// Fetch full details for each task and auto-convert HTML descriptions
|
|
1624
|
+
const tasks = [];
|
|
1625
|
+
let tasksConverted = 0;
|
|
1626
|
+
if (queryResult.workItems && queryResult.workItems.length > 0) {
|
|
1627
|
+
for (const wi of queryResult.workItems) {
|
|
1628
|
+
try {
|
|
1629
|
+
let task = await service.getWorkItem(project, wi.id);
|
|
1630
|
+
// Auto-convert HTML description to markdown unless skipAutoConvert
|
|
1631
|
+
const description = task.fields?.['System.Description'];
|
|
1632
|
+
if (description && !skipAutoConvert && isHtmlContent(description)) {
|
|
1633
|
+
try {
|
|
1634
|
+
await autoConvertFieldsToMarkdown(service, project, wi.id, task.fields, ['System.Description']);
|
|
1635
|
+
// Re-fetch with converted content
|
|
1636
|
+
task = await service.getWorkItem(project, wi.id);
|
|
1637
|
+
tasksConverted++;
|
|
1638
|
+
}
|
|
1639
|
+
catch (convertError) {
|
|
1640
|
+
console.error(`Failed to convert HTML description for task ${wi.id}:`, convertError.message);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
tasks.push(task);
|
|
1644
|
+
}
|
|
1645
|
+
catch (taskError) {
|
|
1646
|
+
console.error(`Error fetching task ${wi.id}:`, taskError.message);
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
// Convert to markdown and save
|
|
1651
|
+
const markdown = tasksToMarkdown(parentWorkItem, tasks, project);
|
|
1652
|
+
const filePath = getTasksFilePath(syncConfig.folder, parentId);
|
|
1653
|
+
await writeWorkItemFile(filePath, markdown);
|
|
1654
|
+
pulled.push({
|
|
1655
|
+
parentId,
|
|
1656
|
+
file: filePath,
|
|
1657
|
+
taskCount: tasks.length,
|
|
1658
|
+
});
|
|
1659
|
+
filesToCommit.push({ filePath, workItemId: parentId });
|
|
1660
|
+
}
|
|
1661
|
+
catch (error) {
|
|
1662
|
+
failed.push({ parentId, error: error.message });
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
// Auto-commit if enabled
|
|
1666
|
+
let committed = false;
|
|
1667
|
+
if (syncConfig.autoCommit && filesToCommit.length > 0) {
|
|
1668
|
+
const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced tasks for');
|
|
1669
|
+
committed = commitResult.committed;
|
|
1670
|
+
}
|
|
1671
|
+
const result = {
|
|
1672
|
+
pulled,
|
|
1673
|
+
failed,
|
|
1674
|
+
folder: syncConfig.folder,
|
|
1675
|
+
committed,
|
|
1676
|
+
};
|
|
1677
|
+
return {
|
|
1678
|
+
content: [{
|
|
1679
|
+
type: "text",
|
|
1680
|
+
text: `Synced tasks for ${pulled.length} parent(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1681
|
+
}],
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
catch (error) {
|
|
1685
|
+
console.error("Error syncing tasks to files:", error);
|
|
1686
|
+
return {
|
|
1687
|
+
content: [{
|
|
1688
|
+
type: "text",
|
|
1689
|
+
text: `Failed to sync tasks: ${error.message}`,
|
|
1690
|
+
}],
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
server.tool("sync-tasks-from-file", descWithExamples("Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Auto-converts HTML fields to markdown. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", SYNC_TASKS_FROM_FILE_EXAMPLES), {
|
|
1695
|
+
project: z.string().describe("The project name"),
|
|
1696
|
+
parentIds: z.array(z.number()).describe("Parent work item IDs to sync tasks for"),
|
|
1697
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1698
|
+
skipAutoConvert: z.boolean().optional().describe("Skip automatic HTML-to-markdown conversion for task descriptions. Only use when explicitly requested. Default: false"),
|
|
1699
|
+
}, async ({ project, parentIds, folder, skipAutoConvert }) => {
|
|
1700
|
+
try {
|
|
1701
|
+
const service = getAzureDevOpsService();
|
|
1702
|
+
const syncConfig = getSyncConfig(folder);
|
|
1703
|
+
// Validate folder path for security
|
|
1704
|
+
validateFolderPath(syncConfig.folder);
|
|
1705
|
+
const updated = [];
|
|
1706
|
+
const created = [];
|
|
1707
|
+
const failed = [];
|
|
1708
|
+
for (const parentId of parentIds) {
|
|
1709
|
+
const filePath = getTasksFilePath(syncConfig.folder, parentId);
|
|
1710
|
+
try {
|
|
1711
|
+
// Check if file exists
|
|
1712
|
+
if (!await fileExists(filePath)) {
|
|
1713
|
+
failed.push({ parentId, error: `File not found: ${filePath}` });
|
|
1714
|
+
continue;
|
|
1715
|
+
}
|
|
1716
|
+
// Parse local file
|
|
1717
|
+
const content = await readFileContent(filePath);
|
|
1718
|
+
const parsed = parseTasksMarkdown(content);
|
|
1719
|
+
// Get parent work item for area/iteration path
|
|
1720
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1721
|
+
const parentFields = parentWorkItem.fields || {};
|
|
1722
|
+
const areaPath = parentFields['System.AreaPath'];
|
|
1723
|
+
const iterationPath = parentFields['System.IterationPath'];
|
|
1724
|
+
const createdInThisFile = [];
|
|
1725
|
+
// Process each task
|
|
1726
|
+
for (const task of parsed.tasks) {
|
|
1727
|
+
try {
|
|
1728
|
+
if (task.id !== null) {
|
|
1729
|
+
// Existing task - update (auto-converts HTML fields unless skipAutoConvert)
|
|
1730
|
+
const currentTask = await service.getWorkItem(project, task.id);
|
|
1731
|
+
const { operations, fieldsUpdated, convertedFields } = buildTaskPatchOperations(task, currentTask, skipAutoConvert);
|
|
1732
|
+
if (operations.length > 0) {
|
|
1733
|
+
await service.updateWorkItem(project, task.id, operations);
|
|
1734
|
+
updated.push({
|
|
1735
|
+
id: task.id,
|
|
1736
|
+
parentId,
|
|
1737
|
+
fieldsUpdated,
|
|
1738
|
+
...(convertedFields.length > 0 ? { convertedFields } : {}),
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
else {
|
|
1742
|
+
// No changes needed
|
|
1743
|
+
updated.push({
|
|
1744
|
+
id: task.id,
|
|
1745
|
+
parentId,
|
|
1746
|
+
fieldsUpdated: [],
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
else {
|
|
1751
|
+
// New task - create
|
|
1752
|
+
if (!task.title) {
|
|
1753
|
+
continue; // Skip tasks without title
|
|
1754
|
+
}
|
|
1755
|
+
const fields = buildNewTaskFields(task, areaPath, iterationPath);
|
|
1756
|
+
const createdTask = await service.createWorkItem(project, 'Task', fields, parentId);
|
|
1757
|
+
created.push({
|
|
1758
|
+
id: createdTask.id,
|
|
1759
|
+
parentId,
|
|
1760
|
+
title: task.title,
|
|
1761
|
+
});
|
|
1762
|
+
createdInThisFile.push({
|
|
1763
|
+
title: task.title,
|
|
1764
|
+
id: createdTask.id,
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
catch (taskError) {
|
|
1769
|
+
failed.push({
|
|
1770
|
+
parentId,
|
|
1771
|
+
taskId: task.id || undefined,
|
|
1772
|
+
error: taskError.message,
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
// Update the file with new task IDs if any were created
|
|
1777
|
+
if (createdInThisFile.length > 0) {
|
|
1778
|
+
const updatedContent = updateTasksFileAfterCreate(content, createdInThisFile);
|
|
1779
|
+
await writeWorkItemFile(filePath, updatedContent);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
catch (error) {
|
|
1783
|
+
failed.push({ parentId, error: error.message });
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const result = {
|
|
1787
|
+
updated,
|
|
1788
|
+
created,
|
|
1789
|
+
failed,
|
|
1790
|
+
folder: syncConfig.folder,
|
|
1791
|
+
};
|
|
1792
|
+
return {
|
|
1793
|
+
content: [{
|
|
1794
|
+
type: "text",
|
|
1795
|
+
text: `Pushed tasks to ADO (${updated.length} updated, ${created.length} created):\n\n${JSON.stringify(result, null, 2)}`,
|
|
1796
|
+
}],
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
catch (error) {
|
|
1800
|
+
console.error("Error syncing tasks from files:", error);
|
|
1801
|
+
return {
|
|
1802
|
+
content: [{
|
|
1803
|
+
type: "text",
|
|
1804
|
+
text: `Failed to push tasks: ${error.message}`,
|
|
1805
|
+
}],
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
903
1809
|
// Log registration summary (enablePullRequestWrite already defined above)
|
|
904
|
-
|
|
905
|
-
|
|
1810
|
+
// Tool count breakdown:
|
|
1811
|
+
// - Wiki: 6 (get-wikis, search-wiki-pages, get-wiki-page, create-wiki-page, update-wiki-page, str-replace-wiki-page)
|
|
1812
|
+
// - Work Item: 7 (get-work-item, query-work-items, get-work-item-comments, add-work-item-comment, update-work-item, create-work-item, delete-work-item)
|
|
1813
|
+
// - Variable Group: 2 (list-variable-groups, get-variable-group)
|
|
1814
|
+
// - PR Read-only: 6 (list-repositories, list-pull-requests, get-pull-request, get-pull-request-threads, get-pull-request-commits, get-pull-request-changes)
|
|
1815
|
+
// - Build Troubleshooting: 3 (get-build-status, get-build-timeline, get-build-logs)
|
|
1816
|
+
// - Work Item Sync: 5 (sync-work-item-to-file, sync-work-item-from-file, check-work-item-markdown, list-synced-work-items, create-user-story-file)
|
|
1817
|
+
// - Task Sync: 2 (sync-tasks-to-file, sync-tasks-from-file)
|
|
1818
|
+
// - Config: 1 (get-configuration)
|
|
1819
|
+
// Total base: 6 + 7 + 2 + 6 + 3 + 5 + 2 + 1 = 32
|
|
1820
|
+
// + PR Write: 7 (add-pull-request-thread, create-pull-request, update-pull-request, complete-pull-request, add-pr-reviewer, vote-pull-request, reply-to-pr-thread) - conditional
|
|
1821
|
+
const baseToolsCount = 32;
|
|
1822
|
+
const prWriteToolsCount = enablePullRequestWrite ? 7 : 0;
|
|
906
1823
|
const totalToolsCount = baseToolsCount + prWriteToolsCount;
|
|
907
1824
|
console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);
|
|
908
1825
|
// NOTE: Admin tools (pipelines, service connections, agent pools, environments)
|