@mcp-consultant-tools/azure-devops 27.0.0-beta.1 → 27.0.0-beta.3
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/index.d.ts.map +1 -1
- package/build/index.js +659 -1
- 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-detection.d.ts +83 -0
- package/build/sync/html-detection.d.ts.map +1 -0
- package/build/sync/html-detection.js +146 -0
- package/build/sync/html-detection.js.map +1 -0
- package/build/sync/index.d.ts +11 -0
- package/build/sync/index.d.ts.map +1 -0
- package/build/sync/index.js +11 -0
- package/build/sync/index.js.map +1 -0
- package/build/sync/markdown-serializer.d.ts +130 -0
- package/build/sync/markdown-serializer.d.ts.map +1 -0
- package/build/sync/markdown-serializer.js +594 -0
- package/build/sync/markdown-serializer.js.map +1 -0
- package/build/sync/task-serializer.d.ts +92 -0
- package/build/sync/task-serializer.d.ts.map +1 -0
- package/build/sync/task-serializer.js +381 -0
- package/build/sync/task-serializer.js.map +1 -0
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -10,6 +10,11 @@ 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 { checkFieldFormats, getConversionInstructions, workItemToMarkdown, commentsToMarkdown, buildPatchOperations, updateSyncRevision, getSyncConfig, ensureFolderExists, getWorkItemFilePath, getCommentsFilePath, fileExists, writeWorkItemFile, readWorkItemFile, readFileContent, listSyncedWorkItems, validateFolderPath, autoCommitMultipleFiles,
|
|
14
|
+
// Task sync utilities
|
|
15
|
+
tasksToMarkdown, parseTasksMarkdown, buildTaskPatchOperations, buildNewTaskFields, getTasksFilePath, updateTasksFileAfterCreate,
|
|
16
|
+
// New work item utilities
|
|
17
|
+
parseNewWorkItemMarkdown, buildNewWorkItemFields, generateNewWorkItemTemplate, convertNewFileToSynced, getNewWorkItemFilePath, findNextNewFileIndex, findNewWorkItemFiles, renameFile, } from './sync/index.js';
|
|
13
18
|
/**
|
|
14
19
|
* Register azure-devops tools and prompts to an MCP server
|
|
15
20
|
* @param server - The MCP server instance
|
|
@@ -316,6 +321,45 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
316
321
|
// ========================================
|
|
317
322
|
// TOOLS
|
|
318
323
|
// ========================================
|
|
324
|
+
server.tool("get-configuration", "Get the configured Azure DevOps organization and projects. Use this to construct correct URLs.", {}, async () => {
|
|
325
|
+
try {
|
|
326
|
+
const organization = process.env.AZUREDEVOPS_ORGANIZATION;
|
|
327
|
+
const projects = process.env.AZUREDEVOPS_PROJECTS?.split(",").map(p => p.trim()).filter(p => p) || [];
|
|
328
|
+
const syncFolder = process.env.AZUREDEVOPS_SYNC_FOLDER || 'docs/user-stories';
|
|
329
|
+
if (!organization || projects.length === 0) {
|
|
330
|
+
return {
|
|
331
|
+
content: [{
|
|
332
|
+
type: "text",
|
|
333
|
+
text: "Azure DevOps not configured. Set AZUREDEVOPS_ORGANIZATION and AZUREDEVOPS_PROJECTS environment variables.",
|
|
334
|
+
}],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const config = {
|
|
338
|
+
organization,
|
|
339
|
+
projects,
|
|
340
|
+
syncFolder,
|
|
341
|
+
urlPatterns: {
|
|
342
|
+
workItem: `https://dev.azure.com/${organization}/{project}/_workitems/edit/{id}`,
|
|
343
|
+
pullRequest: `https://dev.azure.com/${organization}/{project}/_git/{repo}/pullrequest/{id}`,
|
|
344
|
+
wiki: `https://dev.azure.com/${organization}/{project}/_wiki/wikis/{wikiName}/{pagePath}`,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
content: [{
|
|
349
|
+
type: "text",
|
|
350
|
+
text: `Azure DevOps Configuration:\n\n${JSON.stringify(config, null, 2)}`,
|
|
351
|
+
}],
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return {
|
|
356
|
+
content: [{
|
|
357
|
+
type: "text",
|
|
358
|
+
text: `Failed to get configuration: ${error.message}`,
|
|
359
|
+
}],
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
});
|
|
319
363
|
server.tool("get-wikis", "Get all wikis in an Azure DevOps project", {
|
|
320
364
|
project: z.string().describe("The project name"),
|
|
321
365
|
}, async ({ project }) => {
|
|
@@ -900,8 +944,622 @@ export function registerAzureDevOpsTools(server, azuredevopsService) {
|
|
|
900
944
|
};
|
|
901
945
|
}
|
|
902
946
|
});
|
|
947
|
+
// ========================================
|
|
948
|
+
// WORK ITEM SYNC TOOLS (Local Markdown)
|
|
949
|
+
// ========================================
|
|
950
|
+
server.tool("sync-work-item-to-file", "Download work item(s) from ADO and save as local markdown file(s). Token-efficient for editing. Requires markdown format fields (not HTML). Can also pull all child work items under a parent (e.g., all User Stories under a Feature).", {
|
|
951
|
+
project: z.string().describe("The project name"),
|
|
952
|
+
workItemIds: z.array(z.number()).optional().describe("Work item IDs to pull (optional if using parentId)"),
|
|
953
|
+
parentId: z.number().optional().describe("Pull all child work items of this parent (e.g., Feature ID to pull all User Stories)"),
|
|
954
|
+
childType: z.string().optional().describe("Filter by work item type when using parentId (default: 'User Story')"),
|
|
955
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
956
|
+
includeComments: z.boolean().optional().describe("Also save comments to {id}-comments.md (default: false)"),
|
|
957
|
+
}, async ({ project, workItemIds: providedWorkItemIds, parentId, childType, folder, includeComments }) => {
|
|
958
|
+
try {
|
|
959
|
+
const service = getAzureDevOpsService();
|
|
960
|
+
const syncConfig = getSyncConfig(folder);
|
|
961
|
+
// Validate folder path for security
|
|
962
|
+
validateFolderPath(syncConfig.folder);
|
|
963
|
+
// Determine which work items to pull
|
|
964
|
+
let workItemIds = providedWorkItemIds || [];
|
|
965
|
+
// If parentId is provided, query for child work items
|
|
966
|
+
if (parentId) {
|
|
967
|
+
const type = childType || 'User Story';
|
|
968
|
+
const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = '${type}' ORDER BY [System.Id] ASC`;
|
|
969
|
+
const queryResult = await service.queryWorkItems(project, wiql, 200);
|
|
970
|
+
if (queryResult.workItems && queryResult.workItems.length > 0) {
|
|
971
|
+
const childIds = queryResult.workItems.map((wi) => wi.id);
|
|
972
|
+
workItemIds = [...workItemIds, ...childIds];
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// Validate that we have work items to pull
|
|
976
|
+
if (workItemIds.length === 0) {
|
|
977
|
+
return {
|
|
978
|
+
content: [{
|
|
979
|
+
type: "text",
|
|
980
|
+
text: parentId
|
|
981
|
+
? `No ${childType || 'User Story'} work items found under parent #${parentId}`
|
|
982
|
+
: "No work item IDs provided. Specify workItemIds or parentId.",
|
|
983
|
+
}],
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
// Remove duplicates
|
|
987
|
+
workItemIds = [...new Set(workItemIds)];
|
|
988
|
+
const pulled = [];
|
|
989
|
+
const skipped = [];
|
|
990
|
+
const commentsFiles = [];
|
|
991
|
+
const filesToCommit = [];
|
|
992
|
+
await ensureFolderExists(syncConfig.folder);
|
|
993
|
+
for (const workItemId of workItemIds) {
|
|
994
|
+
try {
|
|
995
|
+
// Fetch work item from ADO
|
|
996
|
+
const workItem = await service.getWorkItem(project, workItemId);
|
|
997
|
+
const revision = workItem.rev || workItem._rev || 1;
|
|
998
|
+
// Check field formats
|
|
999
|
+
const formats = checkFieldFormats(workItem);
|
|
1000
|
+
if (!formats.ready) {
|
|
1001
|
+
const htmlFields = [];
|
|
1002
|
+
if (formats.description === 'html')
|
|
1003
|
+
htmlFields.push('Description');
|
|
1004
|
+
if (formats.acceptanceCriteria === 'html')
|
|
1005
|
+
htmlFields.push('Acceptance Criteria');
|
|
1006
|
+
skipped.push({
|
|
1007
|
+
id: workItemId,
|
|
1008
|
+
reason: `HTML fields: ${htmlFields.join(', ')}. Convert to markdown in ADO first.`,
|
|
1009
|
+
});
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
// Convert to markdown and save
|
|
1013
|
+
const { content: markdown, skippedFields: secondarySkipped } = workItemToMarkdown(workItem, revision);
|
|
1014
|
+
const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
|
|
1015
|
+
await writeWorkItemFile(filePath, markdown);
|
|
1016
|
+
pulled.push({
|
|
1017
|
+
id: workItemId,
|
|
1018
|
+
file: filePath,
|
|
1019
|
+
revision,
|
|
1020
|
+
...(secondarySkipped.length > 0 ? { skippedFields: secondarySkipped } : {}),
|
|
1021
|
+
});
|
|
1022
|
+
filesToCommit.push({ filePath, workItemId });
|
|
1023
|
+
// Optionally save comments
|
|
1024
|
+
if (includeComments) {
|
|
1025
|
+
const comments = await service.getWorkItemComments(project, workItemId);
|
|
1026
|
+
const commentsMarkdown = commentsToMarkdown(workItem, comments.comments || []);
|
|
1027
|
+
const commentsPath = getCommentsFilePath(syncConfig.folder, workItemId);
|
|
1028
|
+
await writeWorkItemFile(commentsPath, commentsMarkdown);
|
|
1029
|
+
commentsFiles.push({
|
|
1030
|
+
id: workItemId,
|
|
1031
|
+
file: commentsPath,
|
|
1032
|
+
count: (comments.comments || []).length,
|
|
1033
|
+
});
|
|
1034
|
+
filesToCommit.push({ filePath: commentsPath, workItemId });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
skipped.push({ id: workItemId, reason: error.message });
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
// Auto-commit if enabled
|
|
1042
|
+
let committed = false;
|
|
1043
|
+
if (syncConfig.autoCommit && filesToCommit.length > 0) {
|
|
1044
|
+
const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced');
|
|
1045
|
+
committed = commitResult.committed;
|
|
1046
|
+
}
|
|
1047
|
+
const result = {
|
|
1048
|
+
pulled,
|
|
1049
|
+
skipped,
|
|
1050
|
+
...(includeComments ? { commentsFiles } : {}),
|
|
1051
|
+
folder: syncConfig.folder,
|
|
1052
|
+
committed,
|
|
1053
|
+
};
|
|
1054
|
+
return {
|
|
1055
|
+
content: [{
|
|
1056
|
+
type: "text",
|
|
1057
|
+
text: `Synced ${pulled.length} work item(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1058
|
+
}],
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
console.error("Error syncing work items to files:", error);
|
|
1063
|
+
return {
|
|
1064
|
+
content: [{
|
|
1065
|
+
type: "text",
|
|
1066
|
+
text: `Failed to sync work items: ${error.message}`,
|
|
1067
|
+
}],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
server.tool("sync-work-item-from-file", "Upload local markdown changes back to ADO. Auto-detects new_*.md files and creates them as new work items. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
|
|
1072
|
+
project: z.string().describe("The project name"),
|
|
1073
|
+
workItemIds: z.array(z.number()).optional().describe("Work item IDs to push (optional - new_*.md files are auto-detected)"),
|
|
1074
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1075
|
+
}, async ({ project, workItemIds, folder }) => {
|
|
1076
|
+
try {
|
|
1077
|
+
const service = getAzureDevOpsService();
|
|
1078
|
+
const syncConfig = getSyncConfig(folder);
|
|
1079
|
+
// Validate folder path for security
|
|
1080
|
+
validateFolderPath(syncConfig.folder);
|
|
1081
|
+
const pushed = [];
|
|
1082
|
+
const partial = [];
|
|
1083
|
+
const created = [];
|
|
1084
|
+
const failed = [];
|
|
1085
|
+
// Step 1: Auto-detect and process new_*.md files
|
|
1086
|
+
const newFiles = await findNewWorkItemFiles(syncConfig.folder);
|
|
1087
|
+
for (const filePath of newFiles) {
|
|
1088
|
+
try {
|
|
1089
|
+
// Parse the new work item file
|
|
1090
|
+
const content = await readFileContent(filePath);
|
|
1091
|
+
const parsed = parseNewWorkItemMarkdown(content);
|
|
1092
|
+
// Fetch parent work item to inherit area/iteration paths
|
|
1093
|
+
const parentWorkItem = await service.getWorkItem(project, parsed.frontmatter.parent);
|
|
1094
|
+
// Build fields for creation
|
|
1095
|
+
const fields = buildNewWorkItemFields(parsed, parentWorkItem);
|
|
1096
|
+
// Create the work item in ADO with parent link
|
|
1097
|
+
const createdWorkItem = await service.createWorkItem(project, parsed.frontmatter.type || 'User Story', fields, parsed.frontmatter.parent);
|
|
1098
|
+
const newId = createdWorkItem.id;
|
|
1099
|
+
const revision = createdWorkItem.rev || createdWorkItem._rev || 1;
|
|
1100
|
+
const url = createdWorkItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${newId}`;
|
|
1101
|
+
// Convert the file content to synced format
|
|
1102
|
+
const syncedContent = convertNewFileToSynced(content, newId, revision, url);
|
|
1103
|
+
// Rename file from new_*.md to {id}.md
|
|
1104
|
+
const newFilePath = getWorkItemFilePath(syncConfig.folder, newId);
|
|
1105
|
+
await writeWorkItemFile(newFilePath, syncedContent);
|
|
1106
|
+
await renameFile(filePath, filePath + '.created'); // Mark original as processed
|
|
1107
|
+
// Actually delete the .created file (cleanup)
|
|
1108
|
+
try {
|
|
1109
|
+
const fs = await import('node:fs/promises');
|
|
1110
|
+
await fs.unlink(filePath + '.created');
|
|
1111
|
+
}
|
|
1112
|
+
catch {
|
|
1113
|
+
// Ignore cleanup errors
|
|
1114
|
+
}
|
|
1115
|
+
created.push({
|
|
1116
|
+
id: newId,
|
|
1117
|
+
oldFile: filePath,
|
|
1118
|
+
newFile: newFilePath,
|
|
1119
|
+
parentId: parsed.frontmatter.parent,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
failed.push({ file: filePath, error: error.message });
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// Step 2: Process existing work items (if workItemIds provided)
|
|
1127
|
+
const idsToProcess = workItemIds || [];
|
|
1128
|
+
for (const workItemId of idsToProcess) {
|
|
1129
|
+
const filePath = getWorkItemFilePath(syncConfig.folder, workItemId);
|
|
1130
|
+
try {
|
|
1131
|
+
// Check if file exists
|
|
1132
|
+
if (!await fileExists(filePath)) {
|
|
1133
|
+
failed.push({ id: workItemId, error: `File not found: ${filePath}` });
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
// Parse local file
|
|
1137
|
+
const parsed = await readWorkItemFile(filePath);
|
|
1138
|
+
const oldRevision = parsed.frontmatter.lastSyncedRevision;
|
|
1139
|
+
// Fetch current work item from ADO to compare
|
|
1140
|
+
const currentWorkItem = await service.getWorkItem(project, workItemId);
|
|
1141
|
+
// Build patch operations
|
|
1142
|
+
const { operations, skippedFields } = buildPatchOperations(parsed, currentWorkItem);
|
|
1143
|
+
if (operations.length === 0 && skippedFields.length === 0) {
|
|
1144
|
+
// No changes
|
|
1145
|
+
pushed.push({
|
|
1146
|
+
id: workItemId,
|
|
1147
|
+
oldRevision,
|
|
1148
|
+
newRevision: currentWorkItem.rev || currentWorkItem._rev || oldRevision,
|
|
1149
|
+
fieldsUpdated: [],
|
|
1150
|
+
});
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
// Update ADO if there are operations
|
|
1154
|
+
let newRevision = oldRevision;
|
|
1155
|
+
let fieldsUpdated = [];
|
|
1156
|
+
if (operations.length > 0) {
|
|
1157
|
+
const updatedWorkItem = await service.updateWorkItem(project, workItemId, operations);
|
|
1158
|
+
newRevision = updatedWorkItem.rev || updatedWorkItem._rev || oldRevision + 1;
|
|
1159
|
+
fieldsUpdated = operations.map(op => op.path.replace('/fields/', ''));
|
|
1160
|
+
// Update local file with new revision
|
|
1161
|
+
const content = await readFileContent(filePath);
|
|
1162
|
+
const updatedContent = updateSyncRevision(content, newRevision);
|
|
1163
|
+
await writeWorkItemFile(filePath, updatedContent);
|
|
1164
|
+
}
|
|
1165
|
+
if (skippedFields.length > 0) {
|
|
1166
|
+
partial.push({
|
|
1167
|
+
id: workItemId,
|
|
1168
|
+
oldRevision,
|
|
1169
|
+
newRevision,
|
|
1170
|
+
fieldsUpdated,
|
|
1171
|
+
skippedFields,
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
pushed.push({
|
|
1176
|
+
id: workItemId,
|
|
1177
|
+
oldRevision,
|
|
1178
|
+
newRevision,
|
|
1179
|
+
fieldsUpdated,
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
catch (error) {
|
|
1184
|
+
failed.push({ id: workItemId, error: error.message });
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
const result = {
|
|
1188
|
+
created,
|
|
1189
|
+
pushed,
|
|
1190
|
+
partial,
|
|
1191
|
+
failed,
|
|
1192
|
+
folder: syncConfig.folder,
|
|
1193
|
+
};
|
|
1194
|
+
const summary = [];
|
|
1195
|
+
if (created.length > 0)
|
|
1196
|
+
summary.push(`Created ${created.length} new work item(s)`);
|
|
1197
|
+
if (pushed.length > 0)
|
|
1198
|
+
summary.push(`Updated ${pushed.length} work item(s)`);
|
|
1199
|
+
if (partial.length > 0)
|
|
1200
|
+
summary.push(`Partially updated ${partial.length} work item(s)`);
|
|
1201
|
+
if (failed.length > 0)
|
|
1202
|
+
summary.push(`Failed: ${failed.length}`);
|
|
1203
|
+
return {
|
|
1204
|
+
content: [{
|
|
1205
|
+
type: "text",
|
|
1206
|
+
text: `${summary.join(', ')}:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1207
|
+
}],
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
catch (error) {
|
|
1211
|
+
console.error("Error syncing work items from files:", error);
|
|
1212
|
+
return {
|
|
1213
|
+
content: [{
|
|
1214
|
+
type: "text",
|
|
1215
|
+
text: `Failed to push work items: ${error.message}`,
|
|
1216
|
+
}],
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
server.tool("check-work-item-markdown", "Check if work item fields are markdown (required for sync) or HTML format.", {
|
|
1221
|
+
project: z.string().describe("The project name"),
|
|
1222
|
+
workItemIds: z.array(z.number()).describe("Work item IDs to check"),
|
|
1223
|
+
}, async ({ project, workItemIds }) => {
|
|
1224
|
+
try {
|
|
1225
|
+
const service = getAzureDevOpsService();
|
|
1226
|
+
const results = [];
|
|
1227
|
+
let readyCount = 0;
|
|
1228
|
+
let needsConversionCount = 0;
|
|
1229
|
+
for (const workItemId of workItemIds) {
|
|
1230
|
+
try {
|
|
1231
|
+
const workItem = await service.getWorkItem(project, workItemId);
|
|
1232
|
+
const formats = checkFieldFormats(workItem);
|
|
1233
|
+
results.push({
|
|
1234
|
+
id: workItemId,
|
|
1235
|
+
description: formats.description,
|
|
1236
|
+
acceptanceCriteria: formats.acceptanceCriteria,
|
|
1237
|
+
howToTest: formats.additionalFields.howToTest,
|
|
1238
|
+
predeploymentSteps: formats.additionalFields.predeploymentSteps,
|
|
1239
|
+
postdeploymentSteps: formats.additionalFields.postdeploymentSteps,
|
|
1240
|
+
deploymentInformation: formats.additionalFields.deploymentInformation,
|
|
1241
|
+
ready: formats.ready,
|
|
1242
|
+
...(formats.warnings.length > 0 ? { warnings: formats.warnings } : {}),
|
|
1243
|
+
});
|
|
1244
|
+
if (formats.ready) {
|
|
1245
|
+
readyCount++;
|
|
1246
|
+
}
|
|
1247
|
+
else {
|
|
1248
|
+
needsConversionCount++;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
results.push({
|
|
1253
|
+
id: workItemId,
|
|
1254
|
+
description: 'error',
|
|
1255
|
+
acceptanceCriteria: 'error',
|
|
1256
|
+
ready: false,
|
|
1257
|
+
error: error.message || String(error),
|
|
1258
|
+
});
|
|
1259
|
+
needsConversionCount++;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
const result = {
|
|
1263
|
+
results,
|
|
1264
|
+
summary: {
|
|
1265
|
+
ready: readyCount,
|
|
1266
|
+
needsConversion: needsConversionCount,
|
|
1267
|
+
},
|
|
1268
|
+
conversionInstructions: needsConversionCount > 0 ? getConversionInstructions() : undefined,
|
|
1269
|
+
};
|
|
1270
|
+
return {
|
|
1271
|
+
content: [{
|
|
1272
|
+
type: "text",
|
|
1273
|
+
text: `Work item format check:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1274
|
+
}],
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
catch (error) {
|
|
1278
|
+
console.error("Error checking work item formats:", error);
|
|
1279
|
+
return {
|
|
1280
|
+
content: [{
|
|
1281
|
+
type: "text",
|
|
1282
|
+
text: `Failed to check work item formats: ${error.message}`,
|
|
1283
|
+
}],
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
server.tool("list-synced-work-items", "List work items that have been synced to local markdown files.", {
|
|
1288
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1289
|
+
}, async ({ folder }) => {
|
|
1290
|
+
try {
|
|
1291
|
+
const syncConfig = getSyncConfig(folder);
|
|
1292
|
+
// Validate folder path for security
|
|
1293
|
+
validateFolderPath(syncConfig.folder);
|
|
1294
|
+
const workItems = await listSyncedWorkItems(syncConfig.folder);
|
|
1295
|
+
const result = {
|
|
1296
|
+
workItems,
|
|
1297
|
+
folder: syncConfig.folder,
|
|
1298
|
+
count: workItems.length,
|
|
1299
|
+
};
|
|
1300
|
+
return {
|
|
1301
|
+
content: [{
|
|
1302
|
+
type: "text",
|
|
1303
|
+
text: `Synced work items in ${syncConfig.folder}:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1304
|
+
}],
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
console.error("Error listing synced work items:", error);
|
|
1309
|
+
return {
|
|
1310
|
+
content: [{
|
|
1311
|
+
type: "text",
|
|
1312
|
+
text: `Failed to list synced work items: ${error.message}`,
|
|
1313
|
+
}],
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
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.", {
|
|
1318
|
+
project: z.string().describe("The project name"),
|
|
1319
|
+
parentId: z.number().describe("Parent Feature ID - the new user story will be created under this feature"),
|
|
1320
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1321
|
+
}, async ({ project, parentId, folder }) => {
|
|
1322
|
+
try {
|
|
1323
|
+
const service = getAzureDevOpsService();
|
|
1324
|
+
const syncConfig = getSyncConfig(folder);
|
|
1325
|
+
// Validate folder path for security
|
|
1326
|
+
validateFolderPath(syncConfig.folder);
|
|
1327
|
+
// Fetch parent work item to get title
|
|
1328
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1329
|
+
const parentTitle = parentWorkItem.fields?.['System.Title'] || '';
|
|
1330
|
+
// Find next available index for new file
|
|
1331
|
+
const nextIndex = await findNextNewFileIndex(syncConfig.folder, parentId);
|
|
1332
|
+
const filePath = getNewWorkItemFilePath(syncConfig.folder, parentId, nextIndex);
|
|
1333
|
+
// Generate template content
|
|
1334
|
+
const template = generateNewWorkItemTemplate(parentId, parentTitle, project, 'User Story');
|
|
1335
|
+
// Ensure folder exists and write file
|
|
1336
|
+
await ensureFolderExists(syncConfig.folder);
|
|
1337
|
+
await writeWorkItemFile(filePath, template);
|
|
1338
|
+
const result = {
|
|
1339
|
+
file: filePath,
|
|
1340
|
+
parentId,
|
|
1341
|
+
parentTitle,
|
|
1342
|
+
instructions: [
|
|
1343
|
+
`1. Edit the file to update title, description, and acceptance criteria`,
|
|
1344
|
+
`2. Run sync-work-item-from-file(project: "${project}") to create in ADO`,
|
|
1345
|
+
`3. The file will be renamed to {newId}.md after creation`,
|
|
1346
|
+
],
|
|
1347
|
+
};
|
|
1348
|
+
return {
|
|
1349
|
+
content: [{
|
|
1350
|
+
type: "text",
|
|
1351
|
+
text: `Created new user story template:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1352
|
+
}],
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
console.error("Error creating user story file:", error);
|
|
1357
|
+
return {
|
|
1358
|
+
content: [{
|
|
1359
|
+
type: "text",
|
|
1360
|
+
text: `Failed to create user story file: ${error.message}`,
|
|
1361
|
+
}],
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
// ========================================
|
|
1366
|
+
// TASK SYNC TOOLS
|
|
1367
|
+
// ========================================
|
|
1368
|
+
server.tool("sync-tasks-to-file", "Download all tasks under a parent work item (User Story) to a local markdown file. Supports pulling tasks for multiple parents at once.", {
|
|
1369
|
+
project: z.string().describe("The project name"),
|
|
1370
|
+
parentIds: z.array(z.number()).describe("Parent work item IDs (User Stories) to fetch tasks for"),
|
|
1371
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1372
|
+
}, async ({ project, parentIds, folder }) => {
|
|
1373
|
+
try {
|
|
1374
|
+
const service = getAzureDevOpsService();
|
|
1375
|
+
const syncConfig = getSyncConfig(folder);
|
|
1376
|
+
// Validate folder path for security
|
|
1377
|
+
validateFolderPath(syncConfig.folder);
|
|
1378
|
+
const pulled = [];
|
|
1379
|
+
const failed = [];
|
|
1380
|
+
const filesToCommit = [];
|
|
1381
|
+
await ensureFolderExists(syncConfig.folder);
|
|
1382
|
+
for (const parentId of parentIds) {
|
|
1383
|
+
try {
|
|
1384
|
+
// Fetch parent work item to get title and validate existence
|
|
1385
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1386
|
+
// Query for child tasks using WIQL
|
|
1387
|
+
const wiql = `SELECT [System.Id] FROM WorkItems WHERE [System.Parent] = ${parentId} AND [System.WorkItemType] = 'Task' ORDER BY [System.Id] ASC`;
|
|
1388
|
+
const queryResult = await service.queryWorkItems(project, wiql, 200);
|
|
1389
|
+
// Fetch full details for each task
|
|
1390
|
+
const tasks = [];
|
|
1391
|
+
if (queryResult.workItems && queryResult.workItems.length > 0) {
|
|
1392
|
+
for (const wi of queryResult.workItems) {
|
|
1393
|
+
try {
|
|
1394
|
+
const task = await service.getWorkItem(project, wi.id);
|
|
1395
|
+
tasks.push(task);
|
|
1396
|
+
}
|
|
1397
|
+
catch (taskError) {
|
|
1398
|
+
console.error(`Error fetching task ${wi.id}:`, taskError.message);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// Convert to markdown and save
|
|
1403
|
+
const markdown = tasksToMarkdown(parentWorkItem, tasks, project);
|
|
1404
|
+
const filePath = getTasksFilePath(syncConfig.folder, parentId);
|
|
1405
|
+
await writeWorkItemFile(filePath, markdown);
|
|
1406
|
+
pulled.push({
|
|
1407
|
+
parentId,
|
|
1408
|
+
file: filePath,
|
|
1409
|
+
taskCount: tasks.length,
|
|
1410
|
+
});
|
|
1411
|
+
filesToCommit.push({ filePath, workItemId: parentId });
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
failed.push({ parentId, error: error.message });
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
// Auto-commit if enabled
|
|
1418
|
+
let committed = false;
|
|
1419
|
+
if (syncConfig.autoCommit && filesToCommit.length > 0) {
|
|
1420
|
+
const commitResult = await autoCommitMultipleFiles(filesToCommit, 'synced tasks for');
|
|
1421
|
+
committed = commitResult.committed;
|
|
1422
|
+
}
|
|
1423
|
+
const result = {
|
|
1424
|
+
pulled,
|
|
1425
|
+
failed,
|
|
1426
|
+
folder: syncConfig.folder,
|
|
1427
|
+
committed,
|
|
1428
|
+
};
|
|
1429
|
+
return {
|
|
1430
|
+
content: [{
|
|
1431
|
+
type: "text",
|
|
1432
|
+
text: `Synced tasks for ${pulled.length} parent(s) to local files:\n\n${JSON.stringify(result, null, 2)}`,
|
|
1433
|
+
}],
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
catch (error) {
|
|
1437
|
+
console.error("Error syncing tasks to files:", error);
|
|
1438
|
+
return {
|
|
1439
|
+
content: [{
|
|
1440
|
+
type: "text",
|
|
1441
|
+
text: `Failed to sync tasks: ${error.message}`,
|
|
1442
|
+
}],
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
server.tool("sync-tasks-from-file", "Push local task changes back to ADO with upsert semantics. Existing tasks (## Task #ID) are updated, new tasks (## NEW TASK) are created. Requires AZUREDEVOPS_ENABLE_WORK_ITEM_WRITE=true.", {
|
|
1447
|
+
project: z.string().describe("The project name"),
|
|
1448
|
+
parentIds: z.array(z.number()).describe("Parent work item IDs to sync tasks for"),
|
|
1449
|
+
folder: z.string().optional().describe("Override folder path (default: docs/user-stories or AZUREDEVOPS_SYNC_FOLDER)"),
|
|
1450
|
+
}, async ({ project, parentIds, folder }) => {
|
|
1451
|
+
try {
|
|
1452
|
+
const service = getAzureDevOpsService();
|
|
1453
|
+
const syncConfig = getSyncConfig(folder);
|
|
1454
|
+
// Validate folder path for security
|
|
1455
|
+
validateFolderPath(syncConfig.folder);
|
|
1456
|
+
const updated = [];
|
|
1457
|
+
const created = [];
|
|
1458
|
+
const failed = [];
|
|
1459
|
+
for (const parentId of parentIds) {
|
|
1460
|
+
const filePath = getTasksFilePath(syncConfig.folder, parentId);
|
|
1461
|
+
try {
|
|
1462
|
+
// Check if file exists
|
|
1463
|
+
if (!await fileExists(filePath)) {
|
|
1464
|
+
failed.push({ parentId, error: `File not found: ${filePath}` });
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
// Parse local file
|
|
1468
|
+
const content = await readFileContent(filePath);
|
|
1469
|
+
const parsed = parseTasksMarkdown(content);
|
|
1470
|
+
// Get parent work item for area/iteration path
|
|
1471
|
+
const parentWorkItem = await service.getWorkItem(project, parentId);
|
|
1472
|
+
const parentFields = parentWorkItem.fields || {};
|
|
1473
|
+
const areaPath = parentFields['System.AreaPath'];
|
|
1474
|
+
const iterationPath = parentFields['System.IterationPath'];
|
|
1475
|
+
const createdInThisFile = [];
|
|
1476
|
+
// Process each task
|
|
1477
|
+
for (const task of parsed.tasks) {
|
|
1478
|
+
try {
|
|
1479
|
+
if (task.id !== null) {
|
|
1480
|
+
// Existing task - update
|
|
1481
|
+
const currentTask = await service.getWorkItem(project, task.id);
|
|
1482
|
+
const { operations, fieldsUpdated } = buildTaskPatchOperations(task, currentTask);
|
|
1483
|
+
if (operations.length > 0) {
|
|
1484
|
+
await service.updateWorkItem(project, task.id, operations);
|
|
1485
|
+
updated.push({
|
|
1486
|
+
id: task.id,
|
|
1487
|
+
parentId,
|
|
1488
|
+
fieldsUpdated,
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
// No changes needed
|
|
1493
|
+
updated.push({
|
|
1494
|
+
id: task.id,
|
|
1495
|
+
parentId,
|
|
1496
|
+
fieldsUpdated: [],
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
else {
|
|
1501
|
+
// New task - create
|
|
1502
|
+
if (!task.title) {
|
|
1503
|
+
continue; // Skip tasks without title
|
|
1504
|
+
}
|
|
1505
|
+
const fields = buildNewTaskFields(task, areaPath, iterationPath);
|
|
1506
|
+
const createdTask = await service.createWorkItem(project, 'Task', fields, parentId);
|
|
1507
|
+
created.push({
|
|
1508
|
+
id: createdTask.id,
|
|
1509
|
+
parentId,
|
|
1510
|
+
title: task.title,
|
|
1511
|
+
});
|
|
1512
|
+
createdInThisFile.push({
|
|
1513
|
+
title: task.title,
|
|
1514
|
+
id: createdTask.id,
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
catch (taskError) {
|
|
1519
|
+
failed.push({
|
|
1520
|
+
parentId,
|
|
1521
|
+
taskId: task.id || undefined,
|
|
1522
|
+
error: taskError.message,
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
// Update the file with new task IDs if any were created
|
|
1527
|
+
if (createdInThisFile.length > 0) {
|
|
1528
|
+
const updatedContent = updateTasksFileAfterCreate(content, createdInThisFile);
|
|
1529
|
+
await writeWorkItemFile(filePath, updatedContent);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
catch (error) {
|
|
1533
|
+
failed.push({ parentId, error: error.message });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
const result = {
|
|
1537
|
+
updated,
|
|
1538
|
+
created,
|
|
1539
|
+
failed,
|
|
1540
|
+
folder: syncConfig.folder,
|
|
1541
|
+
};
|
|
1542
|
+
return {
|
|
1543
|
+
content: [{
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: `Pushed tasks to ADO (${updated.length} updated, ${created.length} created):\n\n${JSON.stringify(result, null, 2)}`,
|
|
1546
|
+
}],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
console.error("Error syncing tasks from files:", error);
|
|
1551
|
+
return {
|
|
1552
|
+
content: [{
|
|
1553
|
+
type: "text",
|
|
1554
|
+
text: `Failed to push tasks: ${error.message}`,
|
|
1555
|
+
}],
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
903
1559
|
// Log registration summary (enablePullRequestWrite already defined above)
|
|
904
|
-
const
|
|
1560
|
+
const syncToolsCount = 4; // Work item sync tools
|
|
1561
|
+
const taskSyncToolsCount = 2; // Task sync tools
|
|
1562
|
+
const baseToolsCount = 21 + syncToolsCount + taskSyncToolsCount; // 15 original + 6 PR read-only + 4 work item sync + 2 task sync
|
|
905
1563
|
const prWriteToolsCount = enablePullRequestWrite ? 1 : 0;
|
|
906
1564
|
const totalToolsCount = baseToolsCount + prWriteToolsCount;
|
|
907
1565
|
console.error(`azure-devops tools registered: ${totalToolsCount} tools (${baseToolsCount} base + ${prWriteToolsCount} PR write), 4 prompts`);
|