@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
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown Serialization Utilities
|
|
3
|
+
*
|
|
4
|
+
* Convert between ADO work items and local markdown files.
|
|
5
|
+
* Uses YAML frontmatter for metadata and markdown sections for content.
|
|
6
|
+
*/
|
|
7
|
+
import { isHtmlContent, getAdditionalFieldConfig, ADDITIONAL_FIELD_DISPLAY_NAMES } from './html-detection.js';
|
|
8
|
+
/**
|
|
9
|
+
* Simple YAML serializer for frontmatter
|
|
10
|
+
* Handles basic types: string, number, boolean, arrays
|
|
11
|
+
*/
|
|
12
|
+
function serializeFrontmatter(data) {
|
|
13
|
+
const lines = ['---'];
|
|
14
|
+
for (const [key, value] of Object.entries(data)) {
|
|
15
|
+
if (value === undefined || value === null)
|
|
16
|
+
continue;
|
|
17
|
+
if (Array.isArray(value)) {
|
|
18
|
+
if (value.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
lines.push(`${key}:`);
|
|
21
|
+
for (const item of value) {
|
|
22
|
+
lines.push(`- ${item}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
else if (typeof value === 'string') {
|
|
26
|
+
// Quote strings that might be parsed as other types or contain special chars
|
|
27
|
+
if (value.includes(':') || value.includes('#') || value.includes('\n') ||
|
|
28
|
+
value.match(/^[\d.]+$/) || value === 'true' || value === 'false' ||
|
|
29
|
+
value === 'null' || value === '') {
|
|
30
|
+
lines.push(`${key}: "${value.replace(/"/g, '\\"')}"`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lines.push(`${key}: ${value}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
37
|
+
lines.push(`${key}: ${value}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
lines.push('---');
|
|
41
|
+
return lines.join('\n');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Simple YAML parser for frontmatter
|
|
45
|
+
*/
|
|
46
|
+
function parseFrontmatter(yamlContent) {
|
|
47
|
+
const result = {};
|
|
48
|
+
const lines = yamlContent.split('\n');
|
|
49
|
+
let currentKey = null;
|
|
50
|
+
let currentArray = null;
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
// Skip empty lines
|
|
53
|
+
if (!line.trim())
|
|
54
|
+
continue;
|
|
55
|
+
// Array item
|
|
56
|
+
if (line.match(/^\s*-\s+/)) {
|
|
57
|
+
if (currentKey && currentArray !== null) {
|
|
58
|
+
const value = line.replace(/^\s*-\s+/, '').trim();
|
|
59
|
+
currentArray.push(parseYamlValue(value));
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Key-value pair
|
|
64
|
+
const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)?$/);
|
|
65
|
+
if (match) {
|
|
66
|
+
// Save previous array if any
|
|
67
|
+
if (currentKey && currentArray !== null) {
|
|
68
|
+
result[currentKey] = currentArray;
|
|
69
|
+
}
|
|
70
|
+
currentKey = match[1];
|
|
71
|
+
const rawValue = match[2]?.trim();
|
|
72
|
+
// Check if this is start of array (no value after colon)
|
|
73
|
+
if (!rawValue) {
|
|
74
|
+
currentArray = [];
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
currentArray = null;
|
|
78
|
+
result[currentKey] = parseYamlValue(rawValue);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Save final array if any
|
|
83
|
+
if (currentKey && currentArray !== null) {
|
|
84
|
+
result[currentKey] = currentArray;
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse a YAML value string to appropriate type
|
|
90
|
+
*/
|
|
91
|
+
function parseYamlValue(value) {
|
|
92
|
+
// Remove quotes
|
|
93
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
94
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
95
|
+
return value.slice(1, -1).replace(/\\"/g, '"');
|
|
96
|
+
}
|
|
97
|
+
// Boolean
|
|
98
|
+
if (value === 'true')
|
|
99
|
+
return true;
|
|
100
|
+
if (value === 'false')
|
|
101
|
+
return false;
|
|
102
|
+
// Null
|
|
103
|
+
if (value === 'null' || value === '~')
|
|
104
|
+
return null;
|
|
105
|
+
// Number
|
|
106
|
+
if (value.match(/^-?\d+$/))
|
|
107
|
+
return parseInt(value, 10);
|
|
108
|
+
if (value.match(/^-?\d+\.\d+$/))
|
|
109
|
+
return parseFloat(value);
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Convert an ADO work item to markdown file content
|
|
114
|
+
*
|
|
115
|
+
* @param workItem - The work item from ADO API
|
|
116
|
+
* @param revision - The revision number
|
|
117
|
+
* @returns Object with markdown content and list of skipped fields (if any were HTML)
|
|
118
|
+
*/
|
|
119
|
+
export function workItemToMarkdown(workItem, revision) {
|
|
120
|
+
const fields = workItem.fields || {};
|
|
121
|
+
const fieldConfig = getAdditionalFieldConfig();
|
|
122
|
+
const skippedFields = [];
|
|
123
|
+
// Build frontmatter data
|
|
124
|
+
const frontmatter = {
|
|
125
|
+
id: workItem.id,
|
|
126
|
+
title: fields['System.Title'] || '',
|
|
127
|
+
type: fields['System.WorkItemType'] || 'Unknown',
|
|
128
|
+
state: fields['System.State'] || '',
|
|
129
|
+
url: workItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${workItem.id}`,
|
|
130
|
+
};
|
|
131
|
+
// Optional fields
|
|
132
|
+
if (fields['System.AssignedTo']?.displayName) {
|
|
133
|
+
frontmatter.assignedTo = fields['System.AssignedTo'].displayName;
|
|
134
|
+
}
|
|
135
|
+
if (fields['Microsoft.VSTS.Scheduling.StoryPoints'] !== undefined) {
|
|
136
|
+
frontmatter.storyPoints = fields['Microsoft.VSTS.Scheduling.StoryPoints'];
|
|
137
|
+
}
|
|
138
|
+
if (fields['System.Parent']) {
|
|
139
|
+
frontmatter.parent = fields['System.Parent'];
|
|
140
|
+
}
|
|
141
|
+
// MoSCoW priority (custom field - varies by org)
|
|
142
|
+
const moscowField = fields['Custom.MoSCoW'] || fields['Microsoft.VSTS.Common.Priority'];
|
|
143
|
+
if (moscowField) {
|
|
144
|
+
frontmatter.moscow = moscowField;
|
|
145
|
+
}
|
|
146
|
+
// Tags
|
|
147
|
+
if (fields['System.Tags']) {
|
|
148
|
+
frontmatter.tags = fields['System.Tags'].split(';').map((t) => t.trim()).filter((t) => t);
|
|
149
|
+
}
|
|
150
|
+
if (fields['System.AreaPath']) {
|
|
151
|
+
frontmatter.areaPath = fields['System.AreaPath'];
|
|
152
|
+
}
|
|
153
|
+
if (fields['System.IterationPath']) {
|
|
154
|
+
frontmatter.iterationPath = fields['System.IterationPath'];
|
|
155
|
+
}
|
|
156
|
+
// Sync metadata
|
|
157
|
+
frontmatter.lastSyncedRevision = revision;
|
|
158
|
+
frontmatter.lastSyncedAt = new Date().toISOString();
|
|
159
|
+
// Build content sections
|
|
160
|
+
const description = fields['System.Description'] || '';
|
|
161
|
+
const acceptanceCriteria = fields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
|
|
162
|
+
let content = serializeFrontmatter(frontmatter);
|
|
163
|
+
content += '\n\n# Description\n\n';
|
|
164
|
+
content += description.trim() || '_No description provided._';
|
|
165
|
+
content += '\n\n---\n\n# Acceptance Criteria\n\n';
|
|
166
|
+
content += acceptanceCriteria.trim() || '_No acceptance criteria provided._';
|
|
167
|
+
// Add additional fields if present and in markdown format
|
|
168
|
+
const addOptionalSection = (fieldName, displayName, sectionTitle) => {
|
|
169
|
+
const fieldContent = fields[fieldName];
|
|
170
|
+
if (!fieldContent || !fieldContent.trim()) {
|
|
171
|
+
return; // Field not present, skip silently
|
|
172
|
+
}
|
|
173
|
+
if (isHtmlContent(fieldContent)) {
|
|
174
|
+
skippedFields.push(`${displayName} (HTML)`);
|
|
175
|
+
return; // HTML format, skip with warning
|
|
176
|
+
}
|
|
177
|
+
content += `\n\n---\n\n# ${sectionTitle}\n\n`;
|
|
178
|
+
content += fieldContent.trim();
|
|
179
|
+
};
|
|
180
|
+
addOptionalSection(fieldConfig.howToTest, ADDITIONAL_FIELD_DISPLAY_NAMES.howToTest, 'How to Test');
|
|
181
|
+
addOptionalSection(fieldConfig.predeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.predeploymentSteps, 'Predeployment Steps');
|
|
182
|
+
addOptionalSection(fieldConfig.postdeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.postdeploymentSteps, 'Postdeployment Steps');
|
|
183
|
+
addOptionalSection(fieldConfig.deploymentInformation, ADDITIONAL_FIELD_DISPLAY_NAMES.deploymentInformation, 'Deployment Information');
|
|
184
|
+
content += '\n';
|
|
185
|
+
return { content, skippedFields };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse a markdown file to extract frontmatter and content sections
|
|
189
|
+
*/
|
|
190
|
+
export function parseWorkItemMarkdown(content) {
|
|
191
|
+
// Extract frontmatter between ---
|
|
192
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
193
|
+
if (!frontmatterMatch) {
|
|
194
|
+
throw new Error('Invalid work item markdown file: missing YAML frontmatter');
|
|
195
|
+
}
|
|
196
|
+
const frontmatterRaw = frontmatterMatch[1];
|
|
197
|
+
const frontmatterData = parseFrontmatter(frontmatterRaw);
|
|
198
|
+
// Validate required fields
|
|
199
|
+
if (!frontmatterData.id || typeof frontmatterData.id !== 'number') {
|
|
200
|
+
throw new Error('Invalid work item markdown file: missing or invalid "id" in frontmatter');
|
|
201
|
+
}
|
|
202
|
+
const frontmatter = {
|
|
203
|
+
id: frontmatterData.id,
|
|
204
|
+
title: frontmatterData.title || '',
|
|
205
|
+
type: frontmatterData.type || 'Unknown',
|
|
206
|
+
state: frontmatterData.state || '',
|
|
207
|
+
url: frontmatterData.url || '',
|
|
208
|
+
assignedTo: frontmatterData.assignedTo,
|
|
209
|
+
storyPoints: frontmatterData.storyPoints,
|
|
210
|
+
parent: frontmatterData.parent,
|
|
211
|
+
moscow: frontmatterData.moscow,
|
|
212
|
+
tags: frontmatterData.tags,
|
|
213
|
+
areaPath: frontmatterData.areaPath,
|
|
214
|
+
iterationPath: frontmatterData.iterationPath,
|
|
215
|
+
lastSyncedRevision: frontmatterData.lastSyncedRevision || 0,
|
|
216
|
+
lastSyncedAt: frontmatterData.lastSyncedAt || '',
|
|
217
|
+
};
|
|
218
|
+
// Extract content after frontmatter
|
|
219
|
+
const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
|
|
220
|
+
// Helper to extract a section by heading
|
|
221
|
+
// Looks for # SectionName and captures content until the next # heading or end of file
|
|
222
|
+
const extractSection = (sectionName) => {
|
|
223
|
+
// Match section heading followed by content, stopping at next section divider or heading
|
|
224
|
+
const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}\\s*\\n([\\s\\S]*?)(?=\\n---\\n|\\n#\\s+|$)`, 'i');
|
|
225
|
+
const match = contentAfterFrontmatter.match(regex);
|
|
226
|
+
if (!match)
|
|
227
|
+
return undefined;
|
|
228
|
+
const sectionContent = match[1].trim();
|
|
229
|
+
// Return undefined if it's just the placeholder text
|
|
230
|
+
if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return sectionContent || undefined;
|
|
234
|
+
};
|
|
235
|
+
// Extract primary sections
|
|
236
|
+
let description = extractSection('Description') || '';
|
|
237
|
+
if (description === '_No description provided._') {
|
|
238
|
+
description = '';
|
|
239
|
+
}
|
|
240
|
+
let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
|
|
241
|
+
if (acceptanceCriteria === '_No acceptance criteria provided._') {
|
|
242
|
+
acceptanceCriteria = '';
|
|
243
|
+
}
|
|
244
|
+
// Extract additional fields (optional)
|
|
245
|
+
const additionalFields = {};
|
|
246
|
+
const howToTest = extractSection('How to Test');
|
|
247
|
+
if (howToTest)
|
|
248
|
+
additionalFields.howToTest = howToTest;
|
|
249
|
+
const predeploymentSteps = extractSection('Predeployment Steps');
|
|
250
|
+
if (predeploymentSteps)
|
|
251
|
+
additionalFields.predeploymentSteps = predeploymentSteps;
|
|
252
|
+
const postdeploymentSteps = extractSection('Postdeployment Steps');
|
|
253
|
+
if (postdeploymentSteps)
|
|
254
|
+
additionalFields.postdeploymentSteps = postdeploymentSteps;
|
|
255
|
+
const deploymentInformation = extractSection('Deployment Information');
|
|
256
|
+
if (deploymentInformation)
|
|
257
|
+
additionalFields.deploymentInformation = deploymentInformation;
|
|
258
|
+
return {
|
|
259
|
+
frontmatter,
|
|
260
|
+
description,
|
|
261
|
+
acceptanceCriteria,
|
|
262
|
+
additionalFields,
|
|
263
|
+
rawContent: content,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Escape special regex characters in a string
|
|
268
|
+
*/
|
|
269
|
+
function escapeRegex(str) {
|
|
270
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Convert ADO comments to a read-only markdown file
|
|
274
|
+
*/
|
|
275
|
+
export function commentsToMarkdown(workItem, comments) {
|
|
276
|
+
const fields = workItem.fields || {};
|
|
277
|
+
const frontmatter = {
|
|
278
|
+
id: workItem.id,
|
|
279
|
+
title: fields['System.Title'] || '',
|
|
280
|
+
commentCount: comments.length,
|
|
281
|
+
lastSyncedAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
let content = serializeFrontmatter(frontmatter);
|
|
284
|
+
content += '\n\n# Comments\n\n';
|
|
285
|
+
content += '> **NOTE**: This file is read-only. Comments cannot be pushed back to ADO.\n\n';
|
|
286
|
+
if (comments.length === 0) {
|
|
287
|
+
content += '_No comments on this work item._\n';
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
// Sort comments by date (oldest first)
|
|
291
|
+
const sortedComments = [...comments].sort((a, b) => {
|
|
292
|
+
const dateA = new Date(a.createdDate || a.publishedDate || 0);
|
|
293
|
+
const dateB = new Date(b.createdDate || b.publishedDate || 0);
|
|
294
|
+
return dateA.getTime() - dateB.getTime();
|
|
295
|
+
});
|
|
296
|
+
sortedComments.forEach((comment, index) => {
|
|
297
|
+
content += '---\n\n';
|
|
298
|
+
content += `## Comment #${index + 1}\n`;
|
|
299
|
+
content += `**Author**: ${comment.createdBy?.displayName || 'Unknown'}\n`;
|
|
300
|
+
content += `**Date**: ${comment.createdDate || comment.publishedDate || 'Unknown'}\n\n`;
|
|
301
|
+
content += `${comment.text || comment.content || ''}\n\n`;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return content;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Build ADO patch operations from parsed markdown changes
|
|
308
|
+
* Only updates fields that have actually changed.
|
|
309
|
+
* Auto-converts HTML fields to markdown format unless skipAutoConvert is true.
|
|
310
|
+
*
|
|
311
|
+
* @param parsed - Parsed work item file
|
|
312
|
+
* @param currentWorkItem - Current work item from ADO
|
|
313
|
+
* @param skipAutoConvert - Skip automatic HTML-to-markdown conversion (default: false)
|
|
314
|
+
*/
|
|
315
|
+
export function buildPatchOperations(parsed, currentWorkItem, skipAutoConvert = false) {
|
|
316
|
+
const operations = [];
|
|
317
|
+
const skippedFields = [];
|
|
318
|
+
const convertedFields = [];
|
|
319
|
+
const currentFields = currentWorkItem.fields || {};
|
|
320
|
+
const fieldConfig = getAdditionalFieldConfig();
|
|
321
|
+
// Check Title
|
|
322
|
+
const currentTitle = currentFields['System.Title'] || '';
|
|
323
|
+
if (parsed.frontmatter.title !== currentTitle) {
|
|
324
|
+
operations.push({
|
|
325
|
+
op: 'replace',
|
|
326
|
+
path: '/fields/System.Title',
|
|
327
|
+
value: parsed.frontmatter.title,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// Check Description - always try to set markdown format when writing
|
|
331
|
+
const currentDescription = currentFields['System.Description'] || '';
|
|
332
|
+
const descriptionIsHtml = isHtmlContent(currentDescription);
|
|
333
|
+
if (descriptionIsHtml && skipAutoConvert) {
|
|
334
|
+
// Skip HTML field when skipAutoConvert is true
|
|
335
|
+
skippedFields.push('Description (HTML in ADO - skipAutoConvert=true)');
|
|
336
|
+
}
|
|
337
|
+
else if (parsed.description !== currentDescription || descriptionIsHtml) {
|
|
338
|
+
// Write field value AND always try to set markdown format
|
|
339
|
+
operations.push({
|
|
340
|
+
op: currentDescription ? 'replace' : 'add',
|
|
341
|
+
path: '/fields/System.Description',
|
|
342
|
+
value: parsed.description,
|
|
343
|
+
});
|
|
344
|
+
// Always add format op - API silently ignores if not applicable
|
|
345
|
+
operations.push({
|
|
346
|
+
op: 'add',
|
|
347
|
+
path: '/multilineFieldsFormat/System.Description',
|
|
348
|
+
value: 'Markdown',
|
|
349
|
+
});
|
|
350
|
+
if (descriptionIsHtml) {
|
|
351
|
+
convertedFields.push('Description');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Check Acceptance Criteria - always try to set markdown format when writing
|
|
355
|
+
const currentAC = currentFields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
|
|
356
|
+
const acIsHtml = isHtmlContent(currentAC);
|
|
357
|
+
if (acIsHtml && skipAutoConvert) {
|
|
358
|
+
// Skip HTML field when skipAutoConvert is true
|
|
359
|
+
skippedFields.push('Acceptance Criteria (HTML in ADO - skipAutoConvert=true)');
|
|
360
|
+
}
|
|
361
|
+
else if (parsed.acceptanceCriteria !== currentAC || acIsHtml) {
|
|
362
|
+
// Write field value AND always try to set markdown format
|
|
363
|
+
operations.push({
|
|
364
|
+
op: currentAC ? 'replace' : 'add',
|
|
365
|
+
path: '/fields/Microsoft.VSTS.Common.AcceptanceCriteria',
|
|
366
|
+
value: parsed.acceptanceCriteria,
|
|
367
|
+
});
|
|
368
|
+
// Always add format op - API silently ignores if not applicable
|
|
369
|
+
operations.push({
|
|
370
|
+
op: 'add',
|
|
371
|
+
path: '/multilineFieldsFormat/Microsoft.VSTS.Common.AcceptanceCriteria',
|
|
372
|
+
value: 'Markdown',
|
|
373
|
+
});
|
|
374
|
+
if (acIsHtml) {
|
|
375
|
+
convertedFields.push('Acceptance Criteria');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Helper to check and add patch operation for additional field
|
|
379
|
+
// Always tries to set markdown format when writing to a field
|
|
380
|
+
const checkAdditionalField = (localValue, fieldName, displayName) => {
|
|
381
|
+
const currentValue = currentFields[fieldName] || '';
|
|
382
|
+
const localContent = localValue || '';
|
|
383
|
+
const fieldIsHtml = currentValue && isHtmlContent(currentValue);
|
|
384
|
+
if (fieldIsHtml && skipAutoConvert) {
|
|
385
|
+
// Skip HTML field when skipAutoConvert is true
|
|
386
|
+
skippedFields.push(`${displayName} (HTML in ADO - skipAutoConvert=true)`);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (localContent !== currentValue || fieldIsHtml) {
|
|
390
|
+
// Write field value AND always try to set markdown format
|
|
391
|
+
operations.push({
|
|
392
|
+
op: currentValue ? 'replace' : 'add',
|
|
393
|
+
path: `/fields/${fieldName}`,
|
|
394
|
+
value: localContent,
|
|
395
|
+
});
|
|
396
|
+
// Always add format op - API silently ignores if not applicable
|
|
397
|
+
operations.push({
|
|
398
|
+
op: 'add',
|
|
399
|
+
path: `/multilineFieldsFormat/${fieldName}`,
|
|
400
|
+
value: 'Markdown',
|
|
401
|
+
});
|
|
402
|
+
if (fieldIsHtml) {
|
|
403
|
+
convertedFields.push(displayName);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
// Check additional fields
|
|
408
|
+
checkAdditionalField(parsed.additionalFields.howToTest, fieldConfig.howToTest, ADDITIONAL_FIELD_DISPLAY_NAMES.howToTest);
|
|
409
|
+
checkAdditionalField(parsed.additionalFields.predeploymentSteps, fieldConfig.predeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.predeploymentSteps);
|
|
410
|
+
checkAdditionalField(parsed.additionalFields.postdeploymentSteps, fieldConfig.postdeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.postdeploymentSteps);
|
|
411
|
+
checkAdditionalField(parsed.additionalFields.deploymentInformation, fieldConfig.deploymentInformation, ADDITIONAL_FIELD_DISPLAY_NAMES.deploymentInformation);
|
|
412
|
+
return { operations, skippedFields, convertedFields };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Update the lastSyncedRevision in a markdown file content
|
|
416
|
+
*/
|
|
417
|
+
export function updateSyncRevision(content, newRevision) {
|
|
418
|
+
// Update lastSyncedRevision in frontmatter
|
|
419
|
+
const updatedContent = content.replace(/lastSyncedRevision:\s*\d+/, `lastSyncedRevision: ${newRevision}`);
|
|
420
|
+
// Update lastSyncedAt timestamp
|
|
421
|
+
return updatedContent.replace(/lastSyncedAt:\s*[^\n]+/, `lastSyncedAt: ${new Date().toISOString()}`);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Check if a work item frontmatter indicates a new (not yet created) work item
|
|
425
|
+
* New work items don't have an 'id' field
|
|
426
|
+
*/
|
|
427
|
+
export function isNewWorkItem(frontmatter) {
|
|
428
|
+
return !frontmatter.id || typeof frontmatter.id !== 'number';
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Parse a markdown file for a NEW work item (no id required)
|
|
432
|
+
*/
|
|
433
|
+
export function parseNewWorkItemMarkdown(content) {
|
|
434
|
+
// Extract frontmatter between ---
|
|
435
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
436
|
+
if (!frontmatterMatch) {
|
|
437
|
+
throw new Error('Invalid work item markdown file: missing YAML frontmatter');
|
|
438
|
+
}
|
|
439
|
+
const frontmatterRaw = frontmatterMatch[1];
|
|
440
|
+
const frontmatterData = parseFrontmatter(frontmatterRaw);
|
|
441
|
+
// Validate required fields for new work items
|
|
442
|
+
if (!frontmatterData.parent || typeof frontmatterData.parent !== 'number') {
|
|
443
|
+
throw new Error('New work item files require a "parent" field with the parent work item ID');
|
|
444
|
+
}
|
|
445
|
+
if (!frontmatterData.title || typeof frontmatterData.title !== 'string') {
|
|
446
|
+
throw new Error('New work item files require a "title" field');
|
|
447
|
+
}
|
|
448
|
+
const frontmatter = {
|
|
449
|
+
title: frontmatterData.title,
|
|
450
|
+
type: frontmatterData.type || 'User Story',
|
|
451
|
+
state: frontmatterData.state || 'New',
|
|
452
|
+
parent: frontmatterData.parent,
|
|
453
|
+
assignedTo: frontmatterData.assignedTo,
|
|
454
|
+
storyPoints: frontmatterData.storyPoints,
|
|
455
|
+
moscow: frontmatterData.moscow,
|
|
456
|
+
tags: frontmatterData.tags,
|
|
457
|
+
areaPath: frontmatterData.areaPath,
|
|
458
|
+
iterationPath: frontmatterData.iterationPath,
|
|
459
|
+
};
|
|
460
|
+
// Extract content after frontmatter
|
|
461
|
+
const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
|
|
462
|
+
// Helper to extract a section by heading (reuse from parseWorkItemMarkdown)
|
|
463
|
+
const extractSection = (sectionName) => {
|
|
464
|
+
const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}\\s*\\n([\\s\\S]*?)(?=\\n---\\n|\\n#\\s+|$)`, 'i');
|
|
465
|
+
const match = contentAfterFrontmatter.match(regex);
|
|
466
|
+
if (!match)
|
|
467
|
+
return undefined;
|
|
468
|
+
const sectionContent = match[1].trim();
|
|
469
|
+
if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
return sectionContent || undefined;
|
|
473
|
+
};
|
|
474
|
+
// Extract primary sections
|
|
475
|
+
let description = extractSection('Description') || '';
|
|
476
|
+
if (description === '_No description provided._' || description === '[Your description here]') {
|
|
477
|
+
description = '';
|
|
478
|
+
}
|
|
479
|
+
let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
|
|
480
|
+
if (acceptanceCriteria === '_No acceptance criteria provided._' || acceptanceCriteria === '[Your acceptance criteria here]') {
|
|
481
|
+
acceptanceCriteria = '';
|
|
482
|
+
}
|
|
483
|
+
// Extract additional fields (optional)
|
|
484
|
+
const additionalFields = {};
|
|
485
|
+
const howToTest = extractSection('How to Test');
|
|
486
|
+
if (howToTest)
|
|
487
|
+
additionalFields.howToTest = howToTest;
|
|
488
|
+
const predeploymentSteps = extractSection('Predeployment Steps');
|
|
489
|
+
if (predeploymentSteps)
|
|
490
|
+
additionalFields.predeploymentSteps = predeploymentSteps;
|
|
491
|
+
const postdeploymentSteps = extractSection('Postdeployment Steps');
|
|
492
|
+
if (postdeploymentSteps)
|
|
493
|
+
additionalFields.postdeploymentSteps = postdeploymentSteps;
|
|
494
|
+
const deploymentInformation = extractSection('Deployment Information');
|
|
495
|
+
if (deploymentInformation)
|
|
496
|
+
additionalFields.deploymentInformation = deploymentInformation;
|
|
497
|
+
return {
|
|
498
|
+
frontmatter,
|
|
499
|
+
description,
|
|
500
|
+
acceptanceCriteria,
|
|
501
|
+
additionalFields,
|
|
502
|
+
rawContent: content,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Build ADO fields object for creating a new work item
|
|
507
|
+
* Inherits areaPath and iterationPath from parent work item
|
|
508
|
+
*/
|
|
509
|
+
export function buildNewWorkItemFields(parsed, parentWorkItem) {
|
|
510
|
+
const parentFields = parentWorkItem.fields || {};
|
|
511
|
+
const fieldConfig = getAdditionalFieldConfig();
|
|
512
|
+
const fields = {
|
|
513
|
+
'System.Title': parsed.frontmatter.title,
|
|
514
|
+
'System.State': parsed.frontmatter.state || 'New',
|
|
515
|
+
};
|
|
516
|
+
// Set description if provided
|
|
517
|
+
if (parsed.description) {
|
|
518
|
+
fields['System.Description'] = parsed.description;
|
|
519
|
+
}
|
|
520
|
+
// Set acceptance criteria if provided
|
|
521
|
+
if (parsed.acceptanceCriteria) {
|
|
522
|
+
fields['Microsoft.VSTS.Common.AcceptanceCriteria'] = parsed.acceptanceCriteria;
|
|
523
|
+
}
|
|
524
|
+
// Story points - only set if it's a valid number (including 0)
|
|
525
|
+
// YAML may parse empty value as null/undefined, or string if quoted
|
|
526
|
+
// Using 'any' cast because YAML parser may return types not matching interface
|
|
527
|
+
const storyPointsValue = parsed.frontmatter.storyPoints;
|
|
528
|
+
if (storyPointsValue !== undefined && storyPointsValue !== null && storyPointsValue !== '') {
|
|
529
|
+
const storyPoints = Number(storyPointsValue);
|
|
530
|
+
if (!isNaN(storyPoints)) {
|
|
531
|
+
fields['Microsoft.VSTS.Scheduling.StoryPoints'] = storyPoints;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// MoSCoW (custom field) - only set if non-empty
|
|
535
|
+
const moscowValue = parsed.frontmatter.moscow;
|
|
536
|
+
if (moscowValue && String(moscowValue).trim() !== '') {
|
|
537
|
+
fields['Custom.MoSCoW'] = moscowValue;
|
|
538
|
+
}
|
|
539
|
+
// Tags - ensure it's an array with items before joining
|
|
540
|
+
if (Array.isArray(parsed.frontmatter.tags) && parsed.frontmatter.tags.length > 0) {
|
|
541
|
+
fields['System.Tags'] = parsed.frontmatter.tags.join('; ');
|
|
542
|
+
}
|
|
543
|
+
// Inherit areaPath from parent if not specified
|
|
544
|
+
if (parsed.frontmatter.areaPath) {
|
|
545
|
+
fields['System.AreaPath'] = parsed.frontmatter.areaPath;
|
|
546
|
+
}
|
|
547
|
+
else if (parentFields['System.AreaPath']) {
|
|
548
|
+
fields['System.AreaPath'] = parentFields['System.AreaPath'];
|
|
549
|
+
}
|
|
550
|
+
// Inherit iterationPath from parent if not specified
|
|
551
|
+
if (parsed.frontmatter.iterationPath) {
|
|
552
|
+
fields['System.IterationPath'] = parsed.frontmatter.iterationPath;
|
|
553
|
+
}
|
|
554
|
+
else if (parentFields['System.IterationPath']) {
|
|
555
|
+
fields['System.IterationPath'] = parentFields['System.IterationPath'];
|
|
556
|
+
}
|
|
557
|
+
// Additional fields
|
|
558
|
+
if (parsed.additionalFields.howToTest) {
|
|
559
|
+
fields[fieldConfig.howToTest] = parsed.additionalFields.howToTest;
|
|
560
|
+
}
|
|
561
|
+
if (parsed.additionalFields.predeploymentSteps) {
|
|
562
|
+
fields[fieldConfig.predeploymentSteps] = parsed.additionalFields.predeploymentSteps;
|
|
563
|
+
}
|
|
564
|
+
if (parsed.additionalFields.postdeploymentSteps) {
|
|
565
|
+
fields[fieldConfig.postdeploymentSteps] = parsed.additionalFields.postdeploymentSteps;
|
|
566
|
+
}
|
|
567
|
+
if (parsed.additionalFields.deploymentInformation) {
|
|
568
|
+
fields[fieldConfig.deploymentInformation] = parsed.additionalFields.deploymentInformation;
|
|
569
|
+
}
|
|
570
|
+
return fields;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Generate a new work item template markdown file
|
|
574
|
+
*/
|
|
575
|
+
export function generateNewWorkItemTemplate(parentId, parentTitle, project, workItemType = 'User Story') {
|
|
576
|
+
// Only include required fields - optional fields omitted to avoid empty value issues
|
|
577
|
+
const frontmatter = {
|
|
578
|
+
title: 'New User Story Title',
|
|
579
|
+
type: workItemType,
|
|
580
|
+
state: 'New',
|
|
581
|
+
parent: parentId,
|
|
582
|
+
};
|
|
583
|
+
let content = serializeFrontmatter(frontmatter);
|
|
584
|
+
content += '\n';
|
|
585
|
+
content += `<!-- Optional frontmatter fields (add to YAML above if needed):\n`;
|
|
586
|
+
content += `storyPoints: 3\n`;
|
|
587
|
+
content += `moscow: Should Have\n`;
|
|
588
|
+
content += `tags:\n`;
|
|
589
|
+
content += `- tag1\n`;
|
|
590
|
+
content += `- tag2\n`;
|
|
591
|
+
content += `-->\n\n`;
|
|
592
|
+
content += `> Parent: **#${parentId}** - ${parentTitle}\n`;
|
|
593
|
+
content += `> Project: ${project}\n`;
|
|
594
|
+
content += '\n# Description\n\n';
|
|
595
|
+
content += '[Your description here]\n\n';
|
|
596
|
+
content += '---\n\n';
|
|
597
|
+
content += '# Acceptance Criteria\n\n';
|
|
598
|
+
content += '[Your acceptance criteria here]\n';
|
|
599
|
+
return content;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Update a new work item file after creation with the assigned ID
|
|
603
|
+
* Converts it from a "new" file to a synced file with proper frontmatter
|
|
604
|
+
*/
|
|
605
|
+
export function convertNewFileToSynced(content, workItemId, revision, url) {
|
|
606
|
+
// Parse the existing content
|
|
607
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
608
|
+
if (!frontmatterMatch) {
|
|
609
|
+
throw new Error('Invalid work item markdown file: missing YAML frontmatter');
|
|
610
|
+
}
|
|
611
|
+
const frontmatterRaw = frontmatterMatch[1];
|
|
612
|
+
const frontmatterData = parseFrontmatter(frontmatterRaw);
|
|
613
|
+
const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
|
|
614
|
+
// Build new frontmatter with id and sync metadata
|
|
615
|
+
const newFrontmatter = {
|
|
616
|
+
id: workItemId,
|
|
617
|
+
title: frontmatterData.title || '',
|
|
618
|
+
type: frontmatterData.type || 'User Story',
|
|
619
|
+
state: frontmatterData.state || 'New',
|
|
620
|
+
url: url,
|
|
621
|
+
};
|
|
622
|
+
// Copy optional fields
|
|
623
|
+
if (frontmatterData.assignedTo)
|
|
624
|
+
newFrontmatter.assignedTo = frontmatterData.assignedTo;
|
|
625
|
+
if (frontmatterData.storyPoints)
|
|
626
|
+
newFrontmatter.storyPoints = frontmatterData.storyPoints;
|
|
627
|
+
if (frontmatterData.parent)
|
|
628
|
+
newFrontmatter.parent = frontmatterData.parent;
|
|
629
|
+
if (frontmatterData.moscow)
|
|
630
|
+
newFrontmatter.moscow = frontmatterData.moscow;
|
|
631
|
+
if (frontmatterData.tags && frontmatterData.tags.length > 0)
|
|
632
|
+
newFrontmatter.tags = frontmatterData.tags;
|
|
633
|
+
if (frontmatterData.areaPath)
|
|
634
|
+
newFrontmatter.areaPath = frontmatterData.areaPath;
|
|
635
|
+
if (frontmatterData.iterationPath)
|
|
636
|
+
newFrontmatter.iterationPath = frontmatterData.iterationPath;
|
|
637
|
+
// Add sync metadata
|
|
638
|
+
newFrontmatter.lastSyncedRevision = revision;
|
|
639
|
+
newFrontmatter.lastSyncedAt = new Date().toISOString();
|
|
640
|
+
// Remove the "Parent:" and "Project:" note lines from the content
|
|
641
|
+
let cleanedContent = contentAfterFrontmatter
|
|
642
|
+
.replace(/>\s*Parent:.*\n/i, '')
|
|
643
|
+
.replace(/>\s*Project:.*\n/i, '');
|
|
644
|
+
return serializeFrontmatter(newFrontmatter) + cleanedContent;
|
|
645
|
+
}
|
|
646
|
+
//# sourceMappingURL=markdown-serializer.js.map
|