@mcp-consultant-tools/azure-devops 30.0.0-beta.16 → 30.0.0-beta.18

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.
Files changed (41) hide show
  1. package/build/services/sync-service.d.ts.map +1 -1
  2. package/build/services/sync-service.js +16 -12
  3. package/build/services/sync-service.js.map +1 -1
  4. package/build/sync/annotation-parser.d.ts +49 -0
  5. package/build/sync/annotation-parser.d.ts.map +1 -0
  6. package/build/sync/annotation-parser.js +81 -0
  7. package/build/sync/annotation-parser.js.map +1 -0
  8. package/build/sync/field-aliases.d.ts +35 -0
  9. package/build/sync/field-aliases.d.ts.map +1 -0
  10. package/build/sync/field-aliases.js +76 -0
  11. package/build/sync/field-aliases.js.map +1 -0
  12. package/build/sync/html-detection.d.ts +16 -65
  13. package/build/sync/html-detection.d.ts.map +1 -1
  14. package/build/sync/html-detection.js +63 -112
  15. package/build/sync/html-detection.js.map +1 -1
  16. package/build/sync/image-sync.d.ts +8 -5
  17. package/build/sync/image-sync.d.ts.map +1 -1
  18. package/build/sync/image-sync.js +18 -10
  19. package/build/sync/image-sync.js.map +1 -1
  20. package/build/sync/index.d.ts +4 -0
  21. package/build/sync/index.d.ts.map +1 -1
  22. package/build/sync/index.js +4 -0
  23. package/build/sync/index.js.map +1 -1
  24. package/build/sync/legacy-mappings.d.ts +37 -0
  25. package/build/sync/legacy-mappings.d.ts.map +1 -0
  26. package/build/sync/legacy-mappings.js +75 -0
  27. package/build/sync/legacy-mappings.js.map +1 -0
  28. package/build/sync/markdown-serializer.d.ts +52 -60
  29. package/build/sync/markdown-serializer.d.ts.map +1 -1
  30. package/build/sync/markdown-serializer.js +603 -603
  31. package/build/sync/markdown-serializer.js.map +1 -1
  32. package/build/sync/template-loader.d.ts +56 -0
  33. package/build/sync/template-loader.d.ts.map +1 -0
  34. package/build/sync/template-loader.js +138 -0
  35. package/build/sync/template-loader.js.map +1 -0
  36. package/build/sync/templates/bug.md +25 -0
  37. package/build/sync/templates/epic.md +23 -0
  38. package/build/sync/templates/feature.md +23 -0
  39. package/build/sync/templates/task.md +14 -0
  40. package/build/sync/templates/user-story.md +26 -0
  41. package/package.json +2 -2
@@ -2,32 +2,51 @@
2
2
  * Markdown Serialization Utilities
3
3
  *
4
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
5
+ *
6
+ * Files use YAML frontmatter (scalar fields — ADO refnames or friendly
7
+ * aliases) + body sections (long text fields tagged with
8
+ * `<!-- ado-field: REFNAME -->` comments). The sync engine is generic:
9
+ * any field named in the file is pushed to ADO, any field not named is
10
+ * left alone.
11
+ *
12
+ * Legacy files (pre-annotation) are detected and parsed via a
13
+ * legacy-mappings fallback table. They auto-upgrade to the annotated
14
+ * format on the next pull.
11
15
  */
12
- function serializeFrontmatter(data) {
16
+ import { isHtmlContent } from './html-detection.js';
17
+ import { parseAnnotations, hasAnnotations, serializeAnnotatedSection, } from './annotation-parser.js';
18
+ import { isReservedKey, resolveRefname, } from './field-aliases.js';
19
+ import { resolveLegacyHeading } from './legacy-mappings.js';
20
+ import { applyTemplatePlaceholders, loadTemplate, } from './template-loader.js';
21
+ // ---------------------------------------------------------------------------
22
+ // YAML helpers (frontmatter)
23
+ // ---------------------------------------------------------------------------
24
+ function serializeFrontmatter(data, order) {
13
25
  const lines = ['---'];
14
- for (const [key, value] of Object.entries(data)) {
26
+ const orderedKeys = order ? [...order, ...Object.keys(data).filter((k) => !order.includes(k))] : Object.keys(data);
27
+ for (const key of orderedKeys) {
28
+ if (!(key in data))
29
+ continue;
30
+ const value = data[key];
15
31
  if (value === undefined || value === null)
16
32
  continue;
17
33
  if (Array.isArray(value)) {
18
34
  if (value.length === 0)
19
35
  continue;
20
36
  lines.push(`${key}:`);
21
- for (const item of value) {
37
+ for (const item of value)
22
38
  lines.push(`- ${item}`);
23
- }
24
39
  }
25
40
  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, '\\"')}"`);
41
+ if (value.includes(':') ||
42
+ value.includes('#') ||
43
+ value.includes('\n') ||
44
+ value.match(/^[\d.]+$/) ||
45
+ value === 'true' ||
46
+ value === 'false' ||
47
+ value === 'null' ||
48
+ value === '') {
49
+ lines.push(`${key}: "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
31
50
  }
32
51
  else {
33
52
  lines.push(`${key}: ${value}`);
@@ -40,19 +59,16 @@ function serializeFrontmatter(data) {
40
59
  lines.push('---');
41
60
  return lines.join('\n');
42
61
  }
43
- /**
44
- * Simple YAML parser for frontmatter
45
- */
46
62
  function parseFrontmatter(yamlContent) {
47
63
  const result = {};
48
64
  const lines = yamlContent.split('\n');
49
65
  let currentKey = null;
50
66
  let currentArray = null;
67
+ // Key regex supports ADO refnames with dots (e.g. System.Title, Custom.AgenticData).
68
+ const keyRe = /^([A-Za-z_][A-Za-z0-9_.\-]*)\s*:\s*(.*)?$/;
51
69
  for (const line of lines) {
52
- // Skip empty lines
53
70
  if (!line.trim())
54
71
  continue;
55
- // Array item
56
72
  if (line.match(/^\s*-\s+/)) {
57
73
  if (currentKey && currentArray !== null) {
58
74
  const value = line.replace(/^\s*-\s+/, '').trim();
@@ -60,16 +76,12 @@ function parseFrontmatter(yamlContent) {
60
76
  }
61
77
  continue;
62
78
  }
63
- // Key-value pair
64
- const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)?$/);
79
+ const match = line.match(keyRe);
65
80
  if (match) {
66
- // Save previous array if any
67
- if (currentKey && currentArray !== null) {
81
+ if (currentKey && currentArray !== null)
68
82
  result[currentKey] = currentArray;
69
- }
70
83
  currentKey = match[1];
71
84
  const rawValue = match[2]?.trim();
72
- // Check if this is start of array (no value after colon)
73
85
  if (!rawValue) {
74
86
  currentArray = [];
75
87
  }
@@ -79,213 +91,331 @@ function parseFrontmatter(yamlContent) {
79
91
  }
80
92
  }
81
93
  }
82
- // Save final array if any
83
- if (currentKey && currentArray !== null) {
94
+ if (currentKey && currentArray !== null)
84
95
  result[currentKey] = currentArray;
85
- }
86
96
  return result;
87
97
  }
88
- /**
89
- * Parse a YAML value string to appropriate type
90
- */
91
98
  function parseYamlValue(value) {
92
- // Remove quotes and handle escape sequences
93
- if ((value.startsWith('"') && value.endsWith('"')) ||
94
- (value.startsWith("'") && value.endsWith("'"))) {
99
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
95
100
  return value.slice(1, -1).replace(/\\\\/g, '\\').replace(/\\"/g, '"');
96
101
  }
97
- // Boolean
98
102
  if (value === 'true')
99
103
  return true;
100
104
  if (value === 'false')
101
105
  return false;
102
- // Null
103
106
  if (value === 'null' || value === '~')
104
107
  return null;
105
- // Number
106
108
  if (value.match(/^-?\d+$/))
107
109
  return parseInt(value, 10);
108
110
  if (value.match(/^-?\d+\.\d+$/))
109
111
  return parseFloat(value);
110
112
  return value;
111
113
  }
114
+ // ---------------------------------------------------------------------------
115
+ // Field coercion (ADO value ↔ frontmatter value)
116
+ // ---------------------------------------------------------------------------
117
+ /**
118
+ * Convert a raw ADO field value to a frontmatter-writable scalar or array.
119
+ * Handles the one weird case: `System.AssignedTo` comes back as an object
120
+ * `{displayName, uniqueName, ...}`; frontmatter shows the displayName.
121
+ * `System.Tags` is a semicolon-separated string; frontmatter shows an array.
122
+ */
123
+ function adoValueToFrontmatter(refname, raw) {
124
+ if (raw === undefined || raw === null || raw === '')
125
+ return null;
126
+ if (refname === 'System.AssignedTo') {
127
+ if (typeof raw === 'object' && raw.displayName)
128
+ return raw.displayName;
129
+ if (typeof raw === 'string')
130
+ return raw;
131
+ return null;
132
+ }
133
+ if (refname === 'System.Tags') {
134
+ if (typeof raw !== 'string')
135
+ return null;
136
+ return raw.split(';').map((t) => t.trim()).filter((t) => t);
137
+ }
138
+ if (typeof raw === 'object')
139
+ return String(raw);
140
+ return raw;
141
+ }
142
+ /**
143
+ * Convert a frontmatter value back to the shape ADO expects in a PATCH.
144
+ */
145
+ function frontmatterValueToAdo(refname, value) {
146
+ if (refname === 'System.Tags' && Array.isArray(value)) {
147
+ return value.join('; ');
148
+ }
149
+ return value;
150
+ }
151
+ /**
152
+ * Stable comparison between a frontmatter value and the current ADO value.
153
+ */
154
+ function fieldEquals(refname, local, remote) {
155
+ const remoteNorm = adoValueToFrontmatter(refname, remote);
156
+ if (local === undefined && (remoteNorm === null || remoteNorm === ''))
157
+ return true;
158
+ if (local === undefined || remoteNorm === null)
159
+ return false;
160
+ if (Array.isArray(local) && Array.isArray(remoteNorm)) {
161
+ return local.length === remoteNorm.length && local.every((v, i) => v === remoteNorm[i]);
162
+ }
163
+ return String(local) === String(remoteNorm);
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // DOWN: ADO work item → markdown file
167
+ // ---------------------------------------------------------------------------
112
168
  /**
113
- * Convert an ADO work item to markdown file content
169
+ * Serialize an ADO work item to an annotated markdown file.
114
170
  *
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)
171
+ * Follows the template for `fields['System.WorkItemType']`. Frontmatter
172
+ * order and body-section order come from the template. Fields the ADO
173
+ * response carries but the template doesn't mention are appended to the
174
+ * frontmatter with a discovery comment (see D4 in the design plan).
118
175
  */
119
176
  export function workItemToMarkdown(workItem, revision) {
120
177
  const fields = workItem.fields || {};
121
- const fieldConfig = getAdditionalFieldConfig();
178
+ const workItemType = fields['System.WorkItemType'] || 'Unknown';
179
+ const project = fields['System.TeamProject'] || '';
180
+ const template = applyTemplatePlaceholders(loadTemplate(workItemType), { project });
122
181
  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
- }
182
+ // ----- Frontmatter -----
183
+ const fmData = {};
184
+ const fmOrder = [];
185
+ // Reserved metadata (always at top)
186
+ fmData.id = workItem.id;
187
+ fmOrder.push('id');
188
+ fmData.type = workItemType;
189
+ fmOrder.push('type');
138
190
  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 reproSteps = fields['Microsoft.VSTS.TCM.ReproSteps'] || '';
162
- const acceptanceCriteria = fields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
163
- let content = serializeFrontmatter(frontmatter);
164
- content += '\n\n# Description\n\n';
165
- content += description.trim() || '_No description provided._';
166
- // Repro Steps — only emit when present and in markdown form. HTML repro
167
- // steps are skipped (with warning) and surface via skippedFields.
168
- if (reproSteps.trim()) {
169
- if (isHtmlContent(reproSteps)) {
170
- skippedFields.push('Repro Steps (HTML)');
191
+ fmData.parent = fields['System.Parent'];
192
+ fmOrder.push('parent');
193
+ }
194
+ fmData.url = workItem._links?.html?.href || `https://dev.azure.com/_workitems/edit/${workItem.id}`;
195
+ fmOrder.push('url');
196
+ const bodyRefnames = new Set(template.bodyFields.map((f) => f.refname));
197
+ // Template-declared frontmatter fields (in template order)
198
+ for (const templateKey of template.frontmatterOrder) {
199
+ if (templateKey === 'type')
200
+ continue; // already emitted above
201
+ const refname = resolveRefname(templateKey);
202
+ if (refname === null)
203
+ continue; // reserved keys handled elsewhere
204
+ if (bodyRefnames.has(refname))
205
+ continue; // body fields don't go in frontmatter
206
+ const raw = fields[refname];
207
+ const value = adoValueToFrontmatter(refname, raw);
208
+ if (value === null || value === '') {
209
+ // Keep the template default as an empty placeholder so the agent sees the slot.
210
+ fmData[templateKey] = template.frontmatterDefaults[templateKey] ?? '';
171
211
  }
172
212
  else {
173
- content += '\n\n---\n\n# Repro Steps\n\n';
174
- content += reproSteps.trim();
175
- }
176
- }
177
- content += '\n\n---\n\n# Acceptance Criteria\n\n';
178
- content += acceptanceCriteria.trim() || '_No acceptance criteria provided._';
179
- // Add additional fields if present and in markdown format
180
- const addOptionalSection = (fieldName, displayName, sectionTitle) => {
181
- const fieldContent = fields[fieldName];
182
- if (!fieldContent || !fieldContent.trim()) {
183
- return; // Field not present, skip silently
213
+ fmData[templateKey] = value;
184
214
  }
185
- if (isHtmlContent(fieldContent)) {
186
- skippedFields.push(`${displayName} (HTML)`);
187
- return; // HTML format, skip with warning
215
+ fmOrder.push(templateKey);
216
+ }
217
+ // ADO fields not in the template — surface as discovery entries.
218
+ // Short scalars go to frontmatter; long-form text goes to extra body sections.
219
+ const emittedRefnames = new Set();
220
+ for (const templateKey of template.frontmatterOrder) {
221
+ const r = resolveRefname(templateKey);
222
+ if (r)
223
+ emittedRefnames.add(r);
224
+ }
225
+ const extraBodyFields = [];
226
+ for (const [refname, raw] of Object.entries(fields)) {
227
+ if (emittedRefnames.has(refname))
228
+ continue;
229
+ if (bodyRefnames.has(refname))
230
+ continue;
231
+ if (isIgnoredSystemField(refname))
232
+ continue;
233
+ if (looksLikeBodyField(raw)) {
234
+ extraBodyFields.push({
235
+ refname,
236
+ heading: refnameToHeading(refname),
237
+ content: String(raw),
238
+ isHtml: isHtmlContent(String(raw)),
239
+ });
240
+ continue;
188
241
  }
189
- content += `\n\n---\n\n# ${sectionTitle}\n\n`;
190
- content += fieldContent.trim();
191
- };
192
- addOptionalSection(fieldConfig.howToTest, ADDITIONAL_FIELD_DISPLAY_NAMES.howToTest, 'How to Test');
193
- addOptionalSection(fieldConfig.predeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.predeploymentSteps, 'Predeployment Steps');
194
- addOptionalSection(fieldConfig.postdeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.postdeploymentSteps, 'Postdeployment Steps');
195
- addOptionalSection(fieldConfig.deploymentInformation, ADDITIONAL_FIELD_DISPLAY_NAMES.deploymentInformation, 'Deployment Information');
242
+ const value = adoValueToFrontmatter(refname, raw);
243
+ if (value === null || value === '')
244
+ continue;
245
+ fmData[refname] = value;
246
+ fmOrder.push(refname);
247
+ }
248
+ // Sync metadata (always at bottom)
249
+ fmData.lastSyncedRevision = revision;
250
+ fmOrder.push('lastSyncedRevision');
251
+ fmData.lastSyncedAt = new Date().toISOString();
252
+ fmOrder.push('lastSyncedAt');
253
+ // ----- Body -----
254
+ let content = serializeFrontmatter(fmData, fmOrder);
196
255
  content += '\n';
256
+ for (const bodyField of template.bodyFields) {
257
+ const raw = fields[bodyField.refname] || '';
258
+ if (!raw || !String(raw).trim()) {
259
+ content += '\n' + serializeAnnotatedSection(bodyField.heading, bodyField.refname, '');
260
+ continue;
261
+ }
262
+ if (isHtmlContent(raw)) {
263
+ skippedFields.push(`${bodyField.heading} (HTML)`);
264
+ content += '\n' + serializeAnnotatedSection(bodyField.heading, bodyField.refname, '');
265
+ continue;
266
+ }
267
+ content += '\n' + serializeAnnotatedSection(bodyField.heading, bodyField.refname, String(raw).trim());
268
+ }
269
+ // Extra body fields discovered from the ADO response (not in the template).
270
+ for (const extra of extraBodyFields) {
271
+ if (extra.isHtml) {
272
+ skippedFields.push(`${extra.heading} (HTML)`);
273
+ content += '\n' + serializeAnnotatedSection(extra.heading, extra.refname, '');
274
+ continue;
275
+ }
276
+ content += '\n' + serializeAnnotatedSection(extra.heading, extra.refname, extra.content.trim());
277
+ }
197
278
  return { content, skippedFields };
198
279
  }
199
280
  /**
200
- * Parse a markdown file to extract frontmatter and content sections
281
+ * Turn an ADO refname into a human-friendly heading.
282
+ * `Custom.AgenticData` → "Agentic Data".
283
+ * `Microsoft.VSTS.TCM.SystemInfo` → "System Info".
284
+ * `System.Description` → "Description".
285
+ */
286
+ function refnameToHeading(refname) {
287
+ const last = refname.split('.').pop() || refname;
288
+ // Split camelCase / PascalCase at word boundaries.
289
+ return last
290
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
291
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
292
+ .trim();
293
+ }
294
+ function isIgnoredSystemField(refname) {
295
+ // Board/kanban state per work item — team-specific & not user-editable.
296
+ if (refname.startsWith('WEF_'))
297
+ return true;
298
+ // Computed hierarchy fields (derived from AreaPath/IterationPath).
299
+ if (refname.startsWith('System.AreaLevel') || refname.startsWith('System.IterationLevel'))
300
+ return true;
301
+ if (refname === 'System.AreaId' || refname === 'System.IterationId' || refname === 'System.NodeName')
302
+ return true;
303
+ // Audit and revision metadata — ADO controls these.
304
+ switch (refname) {
305
+ case 'System.Id':
306
+ case 'System.Rev':
307
+ case 'System.WorkItemType':
308
+ case 'System.TeamProject':
309
+ case 'System.CreatedBy':
310
+ case 'System.CreatedDate':
311
+ case 'System.ChangedBy':
312
+ case 'System.ChangedDate':
313
+ case 'System.AuthorizedAs':
314
+ case 'System.AuthorizedDate':
315
+ case 'System.RevisedDate':
316
+ case 'System.BoardColumn':
317
+ case 'System.BoardColumnDone':
318
+ case 'System.BoardLane':
319
+ case 'System.CommentCount':
320
+ case 'System.Watermark':
321
+ case 'System.PersonId':
322
+ case 'System.History':
323
+ case 'System.Reason':
324
+ case 'System.Parent': // surfaced as friendly `parent` in frontmatter
325
+ case 'Microsoft.VSTS.Common.StateChangeDate':
326
+ case 'Microsoft.VSTS.Common.ActivatedDate':
327
+ case 'Microsoft.VSTS.Common.ActivatedBy':
328
+ case 'Microsoft.VSTS.Common.ResolvedDate':
329
+ case 'Microsoft.VSTS.Common.ResolvedBy':
330
+ case 'Microsoft.VSTS.Common.ClosedDate':
331
+ case 'Microsoft.VSTS.Common.ClosedBy':
332
+ return true;
333
+ default:
334
+ return false;
335
+ }
336
+ }
337
+ /**
338
+ * Heuristic: an unknown ADO field should be surfaced as a body section (not
339
+ * a frontmatter scalar) when its value is a long-form text field. A single
340
+ * newline or any HTML pattern is strong evidence that the content belongs
341
+ * in body.
201
342
  */
343
+ function looksLikeBodyField(value) {
344
+ if (typeof value !== 'string')
345
+ return false;
346
+ if (value.includes('\n'))
347
+ return true;
348
+ if (value.length > 200)
349
+ return true;
350
+ if (isHtmlContent(value))
351
+ return true;
352
+ return false;
353
+ }
354
+ // ---------------------------------------------------------------------------
355
+ // UP: markdown file → ADO (parse)
356
+ // ---------------------------------------------------------------------------
202
357
  export function parseWorkItemMarkdown(content) {
203
- // Extract frontmatter between ---
204
358
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
205
359
  if (!frontmatterMatch) {
206
360
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
207
361
  }
208
362
  const frontmatterRaw = frontmatterMatch[1];
209
363
  const frontmatterData = parseFrontmatter(frontmatterRaw);
210
- // Validate required fields
211
364
  if (!frontmatterData.id || typeof frontmatterData.id !== 'number') {
212
365
  throw new Error('Invalid work item markdown file: missing or invalid "id" in frontmatter');
213
366
  }
214
- const frontmatter = {
215
- id: frontmatterData.id,
216
- title: frontmatterData.title || '',
217
- type: frontmatterData.type || 'Unknown',
218
- state: frontmatterData.state || '',
219
- url: frontmatterData.url || '',
220
- assignedTo: frontmatterData.assignedTo,
221
- storyPoints: frontmatterData.storyPoints,
222
- parent: frontmatterData.parent,
223
- moscow: frontmatterData.moscow,
224
- tags: frontmatterData.tags,
225
- areaPath: frontmatterData.areaPath,
226
- iterationPath: frontmatterData.iterationPath,
227
- lastSyncedRevision: frontmatterData.lastSyncedRevision || 0,
228
- lastSyncedAt: frontmatterData.lastSyncedAt || '',
229
- };
230
- // Extract content after frontmatter
231
- const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
232
- // Helper to extract a section by heading
233
- // Looks for # SectionName and captures content until the next # heading or end of file
234
- const extractSection = (sectionName) => {
235
- // Match section heading followed by content, stopping at next level-1 heading or end of file
236
- // Note: \n#\s+ only matches level-1 headings (# Foo), not ## or ### — those are preserved as content
237
- // [^\S\n]* matches horizontal whitespace only (not \n) to avoid consuming the blank line after heading
238
- const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}[^\\S\\n]*\\n([\\s\\S]*?)(?=\\n#\\s+|$)`, 'i');
239
- const match = contentAfterFrontmatter.match(regex);
240
- if (!match)
241
- return undefined;
242
- // Strip trailing --- separators (visual formatting between sections, not content)
243
- const sectionContent = match[1].trim().replace(/(\n---\s*)+$/, '').trim();
244
- // Return undefined if it's just the placeholder text
245
- if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
246
- return undefined;
247
- }
248
- return sectionContent || undefined;
249
- };
250
- // Extract primary sections
251
- let description = extractSection('Description');
252
- if (description === undefined) {
253
- // Only use fallback if there's genuinely no # Description heading at all
254
- const hasDescriptionHeading = /(?:^|\n)#\s+Description[^\S\n]*\n/i.test(contentAfterFrontmatter);
255
- if (!hasDescriptionHeading) {
256
- description = extractFallbackDescription(contentAfterFrontmatter);
257
- }
258
- else {
259
- description = '';
367
+ const body = content.slice(frontmatterMatch[0].length);
368
+ const workItemType = frontmatterData.type || 'Unknown';
369
+ // Build the scalar fieldMap from frontmatter (aliases resolved).
370
+ const fieldMap = {};
371
+ for (const [key, rawValue] of Object.entries(frontmatterData)) {
372
+ if (isReservedKey(key))
373
+ continue;
374
+ if (rawValue === undefined || rawValue === null)
375
+ continue;
376
+ const refname = resolveRefname(key);
377
+ if (!refname)
378
+ continue;
379
+ fieldMap[refname] = rawValue;
380
+ }
381
+ // Parse body sections.
382
+ let bodyFieldMap = {};
383
+ let localOnlySections = [];
384
+ if (hasAnnotations(body)) {
385
+ const parsed = parseAnnotations(body);
386
+ for (const section of parsed.annotated) {
387
+ if (!section.content.trim())
388
+ continue;
389
+ if (bodyFieldMap[section.refname]) {
390
+ bodyFieldMap[section.refname] += '\n\n' + section.content;
391
+ }
392
+ else {
393
+ bodyFieldMap[section.refname] = section.content;
394
+ }
260
395
  }
396
+ localOnlySections = parsed.localOnly;
261
397
  }
262
- if (description === '_No description provided._') {
263
- description = '';
264
- }
265
- let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
266
- if (acceptanceCriteria === '_No acceptance criteria provided._') {
267
- acceptanceCriteria = '';
268
- }
269
- let reproSteps = extractSection('Repro Steps') || '';
270
- if (reproSteps === '_No repro steps provided._') {
271
- reproSteps = '';
272
- }
273
- // Extract additional fields (optional)
274
- const additionalFields = {};
275
- const howToTest = extractSection('How to Test');
276
- if (howToTest)
277
- additionalFields.howToTest = howToTest;
278
- const predeploymentSteps = extractSection('Predeployment Steps');
279
- if (predeploymentSteps)
280
- additionalFields.predeploymentSteps = predeploymentSteps;
281
- const postdeploymentSteps = extractSection('Postdeployment Steps');
282
- if (postdeploymentSteps)
283
- additionalFields.postdeploymentSteps = postdeploymentSteps;
284
- const deploymentInformation = extractSection('Deployment Information');
285
- if (deploymentInformation)
286
- additionalFields.deploymentInformation = deploymentInformation;
398
+ else {
399
+ // Legacy parse: map `#` or `##` headings via the legacy table.
400
+ bodyFieldMap = parseLegacyBody(body, workItemType, localOnlySections);
401
+ }
402
+ // Populate back-compat fields.
403
+ const frontmatter = buildLegacyFrontmatterView(frontmatterData, fieldMap);
404
+ const description = bodyFieldMap['System.Description'] || '';
405
+ const reproSteps = bodyFieldMap['Microsoft.VSTS.TCM.ReproSteps'] || '';
406
+ const acceptanceCriteria = bodyFieldMap['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
407
+ const additionalFields = {
408
+ howToTest: bodyFieldMap['Custom.Howtotest'],
409
+ deploymentInformation: bodyFieldMap['Custom.Deploymentinformation'],
410
+ predeploymentSteps: bodyFieldMap['Custom.7519d1bc-5305-4905-822b-2b380e61b154'],
411
+ postdeploymentSteps: bodyFieldMap['Custom.abd6763f-a242-4938-85ed-bda419e34e7e'],
412
+ };
287
413
  return {
288
414
  frontmatter,
415
+ fieldMap,
416
+ bodyFieldMap,
417
+ localOnlySections,
418
+ workItemType,
289
419
  description,
290
420
  reproSteps,
291
421
  acceptanceCriteria,
@@ -294,502 +424,372 @@ export function parseWorkItemMarkdown(content) {
294
424
  };
295
425
  }
296
426
  /**
297
- * Fallback description extraction for files without a # Description heading.
298
- * Returns all content before the first recognized section heading, with
299
- * HTML template comments stripped.
427
+ * Parse legacy `# Heading` sections using the legacy-mappings fallback.
300
428
  */
301
- const RECOGNIZED_SECTIONS = [
302
- 'Repro Steps',
303
- 'Acceptance Criteria',
304
- 'How to Test',
305
- 'Predeployment Steps',
306
- 'Postdeployment Steps',
307
- 'Deployment Information',
308
- ];
309
- function extractFallbackDescription(contentAfterFrontmatter) {
310
- // Find the earliest recognized section heading
311
- let earliestIndex = contentAfterFrontmatter.length;
312
- for (const section of RECOGNIZED_SECTIONS) {
313
- const regex = new RegExp(`\\n#\\s+${escapeRegex(section)}\\s*\\n`, 'i');
314
- const match = contentAfterFrontmatter.match(regex);
315
- if (match && match.index !== undefined && match.index < earliestIndex) {
316
- earliestIndex = match.index;
429
+ function parseLegacyBody(body, workItemType, localOut) {
430
+ const lines = body.split('\n');
431
+ const headings = [];
432
+ for (let i = 0; i < lines.length; i++) {
433
+ const m = lines[i].match(/^(#{1,2})\s+(.+?)\s*$/);
434
+ if (m)
435
+ headings.push({ index: i, text: m[2].trim(), level: m[1].length });
436
+ }
437
+ const out = {};
438
+ for (let h = 0; h < headings.length; h++) {
439
+ const current = headings[h];
440
+ const next = headings[h + 1];
441
+ const bodyLines = lines.slice(current.index + 1, next ? next.index : lines.length);
442
+ const sectionContent = stripLegacySeparators(bodyLines.join('\n')).trim();
443
+ if (!sectionContent)
444
+ continue;
445
+ if (isPlaceholder(sectionContent, current.text))
446
+ continue;
447
+ const refname = resolveLegacyHeading(current.text, workItemType);
448
+ if (refname) {
449
+ if (out[refname]) {
450
+ out[refname] += '\n\n' + sectionContent;
451
+ }
452
+ else {
453
+ out[refname] = sectionContent;
454
+ }
455
+ }
456
+ else {
457
+ localOut.push({ heading: current.text, content: sectionContent, lineIndex: current.index });
317
458
  }
318
459
  }
319
- let fallback = contentAfterFrontmatter.slice(0, earliestIndex);
320
- // Strip only template-generated HTML comments (frontmatter hints), preserve user comments
321
- fallback = fallback.replace(/<!--\s*Optional frontmatter fields[\s\S]*?-->/g, '');
322
- // Strip trailing --- separators
323
- fallback = fallback.replace(/(\n---\s*)+$/, '');
324
- return fallback.trim();
460
+ return out;
325
461
  }
326
- /**
327
- * Escape special regex characters in a string
328
- */
329
- function escapeRegex(str) {
330
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
462
+ function stripLegacySeparators(content) {
463
+ return content.replace(/(\n\s*---\s*)+\s*$/, '').replace(/^\s*(---\s*\n)+/, '');
331
464
  }
332
- /**
333
- * Convert ADO comments to a read-only markdown file
334
- */
335
- export function commentsToMarkdown(workItem, comments) {
336
- const fields = workItem.fields || {};
337
- const frontmatter = {
338
- id: workItem.id,
339
- title: fields['System.Title'] || '',
340
- commentCount: comments.length,
341
- lastSyncedAt: new Date().toISOString(),
465
+ function isPlaceholder(content, heading) {
466
+ const trimmed = content.trim();
467
+ return (trimmed === `_No ${heading.toLowerCase()} provided._` ||
468
+ trimmed === `[Your ${heading.toLowerCase()} here]`);
469
+ }
470
+ function buildLegacyFrontmatterView(raw, fieldMap) {
471
+ const tagsRaw = fieldMap['System.Tags'];
472
+ const tags = Array.isArray(tagsRaw)
473
+ ? tagsRaw
474
+ : typeof tagsRaw === 'string' && tagsRaw
475
+ ? tagsRaw.split(';').map((t) => t.trim()).filter(Boolean)
476
+ : undefined;
477
+ return {
478
+ id: raw.id,
479
+ title: fieldMap['System.Title'] || '',
480
+ type: raw.type || 'Unknown',
481
+ state: fieldMap['System.State'] || '',
482
+ url: raw.url || '',
483
+ assignedTo: fieldMap['System.AssignedTo'],
484
+ storyPoints: fieldMap['Microsoft.VSTS.Scheduling.StoryPoints'],
485
+ parent: raw.parent,
486
+ moscow: fieldMap['Custom.MoSCoW'],
487
+ tags,
488
+ areaPath: fieldMap['System.AreaPath'],
489
+ iterationPath: fieldMap['System.IterationPath'],
490
+ lastSyncedRevision: raw.lastSyncedRevision || 0,
491
+ lastSyncedAt: raw.lastSyncedAt || '',
342
492
  };
343
- let content = serializeFrontmatter(frontmatter);
344
- content += '\n\n# Comments\n\n';
345
- content += '> **NOTE**: This file is read-only. Comments cannot be pushed back to ADO.\n\n';
346
- if (comments.length === 0) {
347
- content += '_No comments on this work item._\n';
348
- }
349
- else {
350
- // Sort comments by date (oldest first)
351
- const sortedComments = [...comments].sort((a, b) => {
352
- const dateA = new Date(a.createdDate || a.publishedDate || 0);
353
- const dateB = new Date(b.createdDate || b.publishedDate || 0);
354
- return dateA.getTime() - dateB.getTime();
355
- });
356
- sortedComments.forEach((comment, index) => {
357
- content += '---\n\n';
358
- content += `## Comment #${index + 1}\n`;
359
- content += `**Author**: ${comment.createdBy?.displayName || 'Unknown'}\n`;
360
- content += `**Date**: ${comment.createdDate || comment.publishedDate || 'Unknown'}\n\n`;
361
- content += `${comment.text || comment.content || ''}\n\n`;
362
- });
363
- }
364
- return content;
365
493
  }
366
- /**
367
- * Build ADO patch operations from parsed markdown changes
368
- * Only updates fields that have actually changed.
369
- * Auto-converts HTML fields to markdown format unless skipAutoConvert is true.
370
- *
371
- * @param parsed - Parsed work item file
372
- * @param currentWorkItem - Current work item from ADO
373
- * @param skipAutoConvert - Skip automatic HTML-to-markdown conversion (default: false)
374
- */
494
+ // ---------------------------------------------------------------------------
495
+ // UP: build ADO patch operations
496
+ // ---------------------------------------------------------------------------
375
497
  export function buildPatchOperations(parsed, currentWorkItem, skipAutoConvert = false) {
376
498
  const operations = [];
377
499
  const skippedFields = [];
378
500
  const convertedFields = [];
379
501
  const currentFields = currentWorkItem.fields || {};
380
- const fieldConfig = getAdditionalFieldConfig();
381
- // Check Title
382
- const currentTitle = currentFields['System.Title'] || '';
383
- if (parsed.frontmatter.title !== currentTitle) {
384
- operations.push({
385
- op: 'replace',
386
- path: '/fields/System.Title',
387
- value: parsed.frontmatter.title,
388
- });
389
- }
390
- // Check State
391
- const currentState = currentFields['System.State'] || '';
392
- if (parsed.frontmatter.state && parsed.frontmatter.state !== currentState) {
393
- operations.push({
394
- op: 'replace',
395
- path: '/fields/System.State',
396
- value: parsed.frontmatter.state,
397
- });
398
- }
399
- // Check Story Points
400
- const currentStoryPoints = currentFields['Microsoft.VSTS.Scheduling.StoryPoints'];
401
- const localStoryPoints = parsed.frontmatter.storyPoints;
402
- if (localStoryPoints !== undefined && localStoryPoints !== currentStoryPoints) {
403
- operations.push({
404
- op: currentStoryPoints !== undefined ? 'replace' : 'add',
405
- path: '/fields/Microsoft.VSTS.Scheduling.StoryPoints',
406
- value: localStoryPoints,
407
- });
408
- }
409
- // Check Description - always try to set markdown format when writing
410
- const currentDescription = currentFields['System.Description'] || '';
411
- const descriptionIsHtml = isHtmlContent(currentDescription);
412
- if (descriptionIsHtml && skipAutoConvert) {
413
- // Skip HTML field when skipAutoConvert is true
414
- skippedFields.push('Description (HTML in ADO - skipAutoConvert=true)');
415
- }
416
- else if (parsed.description !== currentDescription || descriptionIsHtml) {
417
- // Safety guard: don't push empty content when ADO has existing content
418
- if (!parsed.description && currentDescription) {
419
- skippedFields.push('Description (local file has no content — skipping to prevent data loss)');
502
+ const allRefnames = new Set([
503
+ ...Object.keys(parsed.fieldMap),
504
+ ...Object.keys(parsed.bodyFieldMap),
505
+ ]);
506
+ for (const refname of allRefnames) {
507
+ const isBodyField = refname in parsed.bodyFieldMap;
508
+ const localValue = isBodyField
509
+ ? parsed.bodyFieldMap[refname]
510
+ : parsed.fieldMap[refname];
511
+ const currentRaw = currentFields[refname];
512
+ const currentString = typeof currentRaw === 'string' ? currentRaw : '';
513
+ const isHtmlField = isBodyField && typeof currentRaw === 'string' && isHtmlContent(currentRaw);
514
+ if (isHtmlField && skipAutoConvert) {
515
+ skippedFields.push(`${refname} (HTML in ADO - skipAutoConvert=true)`);
516
+ continue;
420
517
  }
421
- else {
422
- // Write field value
423
- operations.push({
424
- op: currentDescription ? 'replace' : 'add',
425
- path: '/fields/System.Description',
426
- value: parsed.description,
427
- });
428
- // Always set markdown format when writing to ensure ADO renders correctly
429
- operations.push({
430
- op: 'add',
431
- path: '/multilineFieldsFormat/System.Description',
432
- value: 'Markdown',
433
- });
434
- if (descriptionIsHtml) {
435
- convertedFields.push('Description');
518
+ // Data-loss guard: don't overwrite non-empty ADO content with empty local content.
519
+ const localIsEmpty = localValue === undefined ||
520
+ localValue === '' ||
521
+ (Array.isArray(localValue) && localValue.length === 0);
522
+ if (isBodyField) {
523
+ if (localIsEmpty && currentString.trim()) {
524
+ skippedFields.push(`${refname} (local file has no content — skipping to prevent data loss)`);
525
+ continue;
436
526
  }
437
- }
438
- }
439
- // Check Repro Steps - same shape as Description / AC
440
- const currentReproSteps = currentFields['Microsoft.VSTS.TCM.ReproSteps'] || '';
441
- const reproStepsIsHtml = isHtmlContent(currentReproSteps);
442
- if (reproStepsIsHtml && skipAutoConvert) {
443
- skippedFields.push('Repro Steps (HTML in ADO - skipAutoConvert=true)');
444
- }
445
- else if (parsed.reproSteps !== currentReproSteps || reproStepsIsHtml) {
446
- if (!parsed.reproSteps && currentReproSteps) {
447
- skippedFields.push('Repro Steps (local file has no content — skipping to prevent data loss)');
448
- }
449
- else {
527
+ if (localIsEmpty && !currentString)
528
+ continue;
529
+ // Compare stringified body content.
530
+ if (String(localValue) === currentString && !isHtmlField)
531
+ continue;
450
532
  operations.push({
451
- op: currentReproSteps ? 'replace' : 'add',
452
- path: '/fields/Microsoft.VSTS.TCM.ReproSteps',
453
- value: parsed.reproSteps,
533
+ op: currentString ? 'replace' : 'add',
534
+ path: `/fields/${refname}`,
535
+ value: String(localValue),
454
536
  });
455
537
  operations.push({
456
538
  op: 'add',
457
- path: '/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps',
539
+ path: `/multilineFieldsFormat/${refname}`,
458
540
  value: 'Markdown',
459
541
  });
460
- if (reproStepsIsHtml) {
461
- convertedFields.push('Repro Steps');
462
- }
463
- }
464
- }
465
- // Check Acceptance Criteria - always try to set markdown format when writing
466
- const currentAC = currentFields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
467
- const acIsHtml = isHtmlContent(currentAC);
468
- if (acIsHtml && skipAutoConvert) {
469
- // Skip HTML field when skipAutoConvert is true
470
- skippedFields.push('Acceptance Criteria (HTML in ADO - skipAutoConvert=true)');
471
- }
472
- else if (parsed.acceptanceCriteria !== currentAC || acIsHtml) {
473
- // Safety guard: don't push empty content when ADO has existing content
474
- if (!parsed.acceptanceCriteria && currentAC) {
475
- skippedFields.push('Acceptance Criteria (local file has no content — skipping to prevent data loss)');
542
+ if (isHtmlField)
543
+ convertedFields.push(refname);
476
544
  }
477
545
  else {
478
- // Write field value
479
- operations.push({
480
- op: currentAC ? 'replace' : 'add',
481
- path: '/fields/Microsoft.VSTS.Common.AcceptanceCriteria',
482
- value: parsed.acceptanceCriteria,
483
- });
484
- // Always set markdown format when writing to ensure ADO renders correctly
485
- operations.push({
486
- op: 'add',
487
- path: '/multilineFieldsFormat/Microsoft.VSTS.Common.AcceptanceCriteria',
488
- value: 'Markdown',
489
- });
490
- if (acIsHtml) {
491
- convertedFields.push('Acceptance Criteria');
546
+ if (localIsEmpty && (currentRaw === undefined || currentRaw === '' || currentRaw === null)) {
547
+ continue;
492
548
  }
493
- }
494
- }
495
- // Helper to check and add patch operation for additional field
496
- // Always tries to set markdown format when writing to a field
497
- const checkAdditionalField = (localValue, fieldName, displayName) => {
498
- const currentValue = currentFields[fieldName] || '';
499
- const localContent = localValue || '';
500
- const fieldIsHtml = currentValue && isHtmlContent(currentValue);
501
- if (fieldIsHtml && skipAutoConvert) {
502
- // Skip HTML field when skipAutoConvert is true
503
- skippedFields.push(`${displayName} (HTML in ADO - skipAutoConvert=true)`);
504
- return;
505
- }
506
- if (localContent !== currentValue || fieldIsHtml) {
507
- // Safety guard: don't push empty content when ADO has existing content
508
- if (!localContent && currentValue) {
509
- skippedFields.push(`${displayName} (local file has no content — skipping to prevent data loss)`);
510
- return;
549
+ if (fieldEquals(refname, localValue, currentRaw))
550
+ continue;
551
+ if (localIsEmpty && currentRaw !== undefined && currentRaw !== '' && currentRaw !== null) {
552
+ // For scalar fields, allow setting to empty only when user clearly intended it.
553
+ // Convention: undefined (key missing) skip; empty-string key → write empty.
554
+ if (localValue === undefined)
555
+ continue;
511
556
  }
512
- // Write field value
513
557
  operations.push({
514
- op: currentValue ? 'replace' : 'add',
515
- path: `/fields/${fieldName}`,
516
- value: localContent,
558
+ op: currentRaw !== undefined && currentRaw !== null && currentRaw !== '' ? 'replace' : 'add',
559
+ path: `/fields/${refname}`,
560
+ value: frontmatterValueToAdo(refname, localValue),
517
561
  });
518
- // Always set markdown format when writing to ensure ADO renders correctly
519
- operations.push({
520
- op: 'add',
521
- path: `/multilineFieldsFormat/${fieldName}`,
522
- value: 'Markdown',
523
- });
524
- if (fieldIsHtml) {
525
- convertedFields.push(displayName);
526
- }
527
562
  }
528
- };
529
- // Check additional fields
530
- checkAdditionalField(parsed.additionalFields.howToTest, fieldConfig.howToTest, ADDITIONAL_FIELD_DISPLAY_NAMES.howToTest);
531
- checkAdditionalField(parsed.additionalFields.predeploymentSteps, fieldConfig.predeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.predeploymentSteps);
532
- checkAdditionalField(parsed.additionalFields.postdeploymentSteps, fieldConfig.postdeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.postdeploymentSteps);
533
- checkAdditionalField(parsed.additionalFields.deploymentInformation, fieldConfig.deploymentInformation, ADDITIONAL_FIELD_DISPLAY_NAMES.deploymentInformation);
563
+ }
534
564
  return { operations, skippedFields, convertedFields };
535
565
  }
536
- /**
537
- * Update the lastSyncedRevision in a markdown file content
538
- */
539
- export function updateSyncRevision(content, newRevision) {
540
- // Update lastSyncedRevision in frontmatter
541
- const updatedContent = content.replace(/lastSyncedRevision:\s*\d+/, `lastSyncedRevision: ${newRevision}`);
542
- // Update lastSyncedAt timestamp
543
- return updatedContent.replace(/lastSyncedAt:\s*[^\n]+/, `lastSyncedAt: ${new Date().toISOString()}`);
544
- }
545
- /**
546
- * Check if a work item frontmatter indicates a new (not yet created) work item
547
- * New work items don't have an 'id' field
548
- */
549
566
  export function isNewWorkItem(frontmatter) {
550
567
  return !frontmatter.id || typeof frontmatter.id !== 'number';
551
568
  }
552
- /**
553
- * Parse a markdown file for a NEW work item (no id required)
554
- */
555
569
  export function parseNewWorkItemMarkdown(content) {
556
- // Extract frontmatter between ---
557
570
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
558
571
  if (!frontmatterMatch) {
559
572
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
560
573
  }
561
- const frontmatterRaw = frontmatterMatch[1];
562
- const frontmatterData = parseFrontmatter(frontmatterRaw);
563
- // Validate required fields for new work items
574
+ const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
564
575
  if (!frontmatterData.title || typeof frontmatterData.title !== 'string') {
565
576
  throw new Error('New work item files require a "title" field');
566
577
  }
567
- // Validate parent is a number if present
568
- if (frontmatterData.parent !== undefined && frontmatterData.parent !== null && typeof frontmatterData.parent !== 'number') {
578
+ if (frontmatterData.parent !== undefined &&
579
+ frontmatterData.parent !== null &&
580
+ typeof frontmatterData.parent !== 'number') {
569
581
  throw new Error('The "parent" field must be a number (work item ID)');
570
582
  }
583
+ const body = content.slice(frontmatterMatch[0].length);
584
+ const workItemType = frontmatterData.type || 'User Story';
585
+ const fieldMap = {};
586
+ for (const [key, rawValue] of Object.entries(frontmatterData)) {
587
+ if (isReservedKey(key))
588
+ continue;
589
+ if (rawValue === undefined || rawValue === null)
590
+ continue;
591
+ const refname = resolveRefname(key);
592
+ if (!refname)
593
+ continue;
594
+ fieldMap[refname] = rawValue;
595
+ }
596
+ let bodyFieldMap = {};
597
+ const localOnlySections = [];
598
+ if (hasAnnotations(body)) {
599
+ const parsed = parseAnnotations(body);
600
+ for (const section of parsed.annotated) {
601
+ if (!section.content.trim())
602
+ continue;
603
+ if (isPlaceholder(section.content, section.heading))
604
+ continue;
605
+ bodyFieldMap[section.refname] = bodyFieldMap[section.refname]
606
+ ? bodyFieldMap[section.refname] + '\n\n' + section.content
607
+ : section.content;
608
+ }
609
+ localOnlySections.push(...parsed.localOnly);
610
+ }
611
+ else {
612
+ bodyFieldMap = parseLegacyBody(body, workItemType, localOnlySections);
613
+ }
614
+ const tagsRaw = fieldMap['System.Tags'];
615
+ const tags = Array.isArray(tagsRaw)
616
+ ? tagsRaw
617
+ : typeof tagsRaw === 'string' && tagsRaw
618
+ ? tagsRaw.split(';').map((t) => t.trim()).filter(Boolean)
619
+ : undefined;
571
620
  const frontmatter = {
572
621
  title: frontmatterData.title,
573
- type: frontmatterData.type || 'User Story',
622
+ type: workItemType,
574
623
  state: frontmatterData.state || 'New',
575
624
  parent: frontmatterData.parent ?? undefined,
576
- assignedTo: frontmatterData.assignedTo,
577
- storyPoints: frontmatterData.storyPoints,
578
- moscow: frontmatterData.moscow,
579
- tags: frontmatterData.tags,
580
- areaPath: frontmatterData.areaPath,
581
- iterationPath: frontmatterData.iterationPath,
582
- };
583
- // Extract content after frontmatter
584
- const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
585
- // Helper to extract a section by heading (reuse from parseWorkItemMarkdown)
586
- const extractSection = (sectionName) => {
587
- const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}[^\\S\\n]*\\n([\\s\\S]*?)(?=\\n#\\s+|$)`, 'i');
588
- const match = contentAfterFrontmatter.match(regex);
589
- if (!match)
590
- return undefined;
591
- // Strip trailing --- separators (visual formatting between sections, not content)
592
- const sectionContent = match[1].trim().replace(/(\n---\s*)+$/, '').trim();
593
- if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
594
- return undefined;
595
- }
596
- return sectionContent || undefined;
625
+ assignedTo: fieldMap['System.AssignedTo'],
626
+ storyPoints: fieldMap['Microsoft.VSTS.Scheduling.StoryPoints'],
627
+ moscow: fieldMap['Custom.MoSCoW'],
628
+ tags,
629
+ areaPath: fieldMap['System.AreaPath'],
630
+ iterationPath: fieldMap['System.IterationPath'],
597
631
  };
598
- // Extract primary sections
599
- let description = extractSection('Description') || '';
600
- if (description === '_No description provided._' || description === '[Your description here]') {
601
- description = '';
602
- }
603
- let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
604
- if (acceptanceCriteria === '_No acceptance criteria provided._' || acceptanceCriteria === '[Your acceptance criteria here]') {
605
- acceptanceCriteria = '';
606
- }
607
- let reproSteps = extractSection('Repro Steps') || '';
608
- if (reproSteps === '_No repro steps provided._' || reproSteps === '[Your repro steps here]') {
609
- reproSteps = '';
610
- }
611
- // Extract additional fields (optional)
612
- const additionalFields = {};
613
- const howToTest = extractSection('How to Test');
614
- if (howToTest)
615
- additionalFields.howToTest = howToTest;
616
- const predeploymentSteps = extractSection('Predeployment Steps');
617
- if (predeploymentSteps)
618
- additionalFields.predeploymentSteps = predeploymentSteps;
619
- const postdeploymentSteps = extractSection('Postdeployment Steps');
620
- if (postdeploymentSteps)
621
- additionalFields.postdeploymentSteps = postdeploymentSteps;
622
- const deploymentInformation = extractSection('Deployment Information');
623
- if (deploymentInformation)
624
- additionalFields.deploymentInformation = deploymentInformation;
625
632
  return {
626
633
  frontmatter,
627
- description,
628
- reproSteps,
629
- acceptanceCriteria,
630
- additionalFields,
634
+ fieldMap,
635
+ bodyFieldMap,
636
+ localOnlySections,
637
+ workItemType,
638
+ description: bodyFieldMap['System.Description'] || '',
639
+ reproSteps: bodyFieldMap['Microsoft.VSTS.TCM.ReproSteps'] || '',
640
+ acceptanceCriteria: bodyFieldMap['Microsoft.VSTS.Common.AcceptanceCriteria'] || '',
641
+ additionalFields: {
642
+ howToTest: bodyFieldMap['Custom.Howtotest'],
643
+ deploymentInformation: bodyFieldMap['Custom.Deploymentinformation'],
644
+ predeploymentSteps: bodyFieldMap['Custom.7519d1bc-5305-4905-822b-2b380e61b154'],
645
+ postdeploymentSteps: bodyFieldMap['Custom.abd6763f-a242-4938-85ed-bda419e34e7e'],
646
+ },
631
647
  rawContent: content,
632
648
  };
633
649
  }
634
- /**
635
- * Build ADO fields object for creating a new work item
636
- * Inherits areaPath and iterationPath from parent work item when available
637
- *
638
- * Returns fields split into standard (safe for creation) and custom (require
639
- * a follow-up update) because ADO rejects custom fields during work item creation.
640
- */
641
650
  export function buildNewWorkItemFields(parsed, parentWorkItem) {
642
651
  const parentFields = parentWorkItem?.fields || {};
643
- const fieldConfig = getAdditionalFieldConfig();
644
- const standardFields = {
645
- 'System.Title': parsed.frontmatter.title,
646
- 'System.State': parsed.frontmatter.state || 'New',
647
- };
648
- // Set description if provided
649
- if (parsed.description) {
650
- standardFields['System.Description'] = parsed.description;
651
- }
652
- // Set acceptance criteria if provided
653
- if (parsed.acceptanceCriteria) {
654
- standardFields['Microsoft.VSTS.Common.AcceptanceCriteria'] = parsed.acceptanceCriteria;
655
- }
656
- // Set repro steps if provided (typically for Bug type)
657
- if (parsed.reproSteps) {
658
- standardFields['Microsoft.VSTS.TCM.ReproSteps'] = parsed.reproSteps;
659
- }
660
- // Story points - only set if it's a valid number (including 0)
661
- // YAML may parse empty value as null/undefined, or string if quoted
662
- // Using 'any' cast because YAML parser may return types not matching interface
663
- const storyPointsValue = parsed.frontmatter.storyPoints;
664
- if (storyPointsValue !== undefined && storyPointsValue !== null && storyPointsValue !== '') {
665
- const storyPoints = Number(storyPointsValue);
666
- if (!isNaN(storyPoints)) {
667
- standardFields['Microsoft.VSTS.Scheduling.StoryPoints'] = storyPoints;
668
- }
669
- }
670
- // Tags - ensure it's an array with items before joining
671
- if (Array.isArray(parsed.frontmatter.tags) && parsed.frontmatter.tags.length > 0) {
672
- standardFields['System.Tags'] = parsed.frontmatter.tags.join('; ');
652
+ const standardFields = {};
653
+ const customFields = {};
654
+ // Title is mandatory; force it from the top-level frontmatter shape.
655
+ standardFields['System.Title'] = parsed.frontmatter.title;
656
+ standardFields['System.State'] = parsed.frontmatter.state || 'New';
657
+ // Fold in every scalar field from the fieldMap.
658
+ for (const [refname, value] of Object.entries(parsed.fieldMap)) {
659
+ if (value === undefined || value === null || value === '')
660
+ continue;
661
+ const target = isStandardRefname(refname) ? standardFields : customFields;
662
+ target[refname] = frontmatterValueToAdo(refname, value);
673
663
  }
674
- // Inherit areaPath from parent if not specified
675
- if (parsed.frontmatter.areaPath) {
676
- standardFields['System.AreaPath'] = parsed.frontmatter.areaPath;
664
+ // Fold in every body field (description, AC, repro steps, custom body fields).
665
+ for (const [refname, value] of Object.entries(parsed.bodyFieldMap)) {
666
+ if (!value || !value.trim())
667
+ continue;
668
+ const target = isStandardRefname(refname) ? standardFields : customFields;
669
+ target[refname] = value;
677
670
  }
678
- else if (parentFields['System.AreaPath']) {
671
+ // Inherit area/iteration path from parent when not provided.
672
+ if (!standardFields['System.AreaPath'] && parentFields['System.AreaPath']) {
679
673
  standardFields['System.AreaPath'] = parentFields['System.AreaPath'];
680
674
  }
681
- // Inherit iterationPath from parent if not specified
682
- if (parsed.frontmatter.iterationPath) {
683
- standardFields['System.IterationPath'] = parsed.frontmatter.iterationPath;
684
- }
685
- else if (parentFields['System.IterationPath']) {
675
+ if (!standardFields['System.IterationPath'] && parentFields['System.IterationPath']) {
686
676
  standardFields['System.IterationPath'] = parentFields['System.IterationPath'];
687
677
  }
688
- // Custom fields — NOT valid during creation, must be set via follow-up update
689
- const customFields = {};
690
- // MoSCoW (custom field) - only set if non-empty
691
- const moscowValue = parsed.frontmatter.moscow;
692
- if (moscowValue && String(moscowValue).trim() !== '') {
693
- customFields['Custom.MoSCoW'] = moscowValue;
694
- }
695
- // Additional custom fields
696
- if (parsed.additionalFields.howToTest) {
697
- customFields[fieldConfig.howToTest] = parsed.additionalFields.howToTest;
698
- }
699
- if (parsed.additionalFields.predeploymentSteps) {
700
- customFields[fieldConfig.predeploymentSteps] = parsed.additionalFields.predeploymentSteps;
701
- }
702
- if (parsed.additionalFields.postdeploymentSteps) {
703
- customFields[fieldConfig.postdeploymentSteps] = parsed.additionalFields.postdeploymentSteps;
704
- }
705
- if (parsed.additionalFields.deploymentInformation) {
706
- customFields[fieldConfig.deploymentInformation] = parsed.additionalFields.deploymentInformation;
707
- }
708
678
  return { standardFields, customFields };
709
679
  }
710
- /**
711
- * Generate a new work item template markdown file
712
- * When parentId is undefined, creates a standalone work item template
713
- */
680
+ function isStandardRefname(refname) {
681
+ return refname.startsWith('System.') || refname.startsWith('Microsoft.VSTS.');
682
+ }
683
+ // ---------------------------------------------------------------------------
684
+ // Template generation (new work item file scaffold)
685
+ // ---------------------------------------------------------------------------
714
686
  export function generateNewWorkItemTemplate(parentId, parentTitle, project, workItemType = 'User Story') {
715
- // Only include required fields - optional fields omitted to avoid empty value issues
716
- const frontmatter = {
717
- title: `New ${workItemType} Title`,
718
- type: workItemType,
719
- state: 'New',
720
- };
721
- // Only include parent in frontmatter when provided
687
+ const template = applyTemplatePlaceholders(loadTemplate(workItemType), {
688
+ project,
689
+ parent: parentId,
690
+ });
691
+ const fmData = { ...template.frontmatterDefaults };
692
+ const fmOrder = [...template.frontmatterOrder];
693
+ // Default `title` to a descriptive placeholder when blank in the template.
694
+ const titleKey = template.frontmatterOrder.includes('title')
695
+ ? 'title'
696
+ : Object.keys(fmData).find((k) => resolveRefname(k) === 'System.Title');
697
+ if (titleKey && (!fmData[titleKey] || fmData[titleKey] === '')) {
698
+ fmData[titleKey] = `New ${workItemType} Title`;
699
+ }
722
700
  if (parentId !== undefined) {
723
- frontmatter.parent = parentId;
701
+ fmData.parent = parentId;
702
+ if (!fmOrder.includes('parent'))
703
+ fmOrder.splice(1, 0, 'parent'); // after `type`
724
704
  }
725
- let content = serializeFrontmatter(frontmatter);
705
+ let content = serializeFrontmatter(fmData, fmOrder);
726
706
  content += '\n';
727
- content += `<!-- Optional frontmatter fields (add to YAML above if needed):\n`;
728
- if (parentId === undefined) {
729
- content += `parent: 12345\n`;
730
- }
731
- content += `storyPoints: 3\n`;
732
- content += `moscow: Should Have\n`;
733
- content += `areaPath: Project\\Area\n`;
734
- content += `iterationPath: Project\\Sprint 1\n`;
735
- content += `tags:\n`;
736
- content += `- tag1\n`;
737
- content += `- tag2\n`;
738
- content += `-->\n\n`;
739
707
  if (parentId !== undefined) {
740
- content += `> Parent: **#${parentId}** - ${parentTitle}\n`;
708
+ content += `\n> Parent: **#${parentId}** - ${parentTitle}\n`;
741
709
  }
742
710
  content += `> Project: ${project}\n`;
743
- content += '\n# Description\n\n';
744
- content += '[Your description here]\n\n';
745
- content += '---\n\n';
746
- content += '# Acceptance Criteria\n\n';
747
- content += '[Your acceptance criteria here]\n';
711
+ for (const bodyField of template.bodyFields) {
712
+ content += '\n' + serializeAnnotatedSection(bodyField.heading, bodyField.refname, '');
713
+ }
748
714
  return content;
749
715
  }
750
- /**
751
- * Update a new work item file after creation with the assigned ID
752
- * Converts it from a "new" file to a synced file with proper frontmatter
753
- */
716
+ // ---------------------------------------------------------------------------
717
+ // Comments (unchanged read-only export)
718
+ // ---------------------------------------------------------------------------
719
+ export function commentsToMarkdown(workItem, comments) {
720
+ const fields = workItem.fields || {};
721
+ const frontmatter = {
722
+ id: workItem.id,
723
+ title: fields['System.Title'] || '',
724
+ commentCount: comments.length,
725
+ lastSyncedAt: new Date().toISOString(),
726
+ };
727
+ let content = serializeFrontmatter(frontmatter);
728
+ content += '\n\n# Comments\n\n';
729
+ content += '> **NOTE**: This file is read-only. Comments cannot be pushed back to ADO.\n\n';
730
+ if (comments.length === 0) {
731
+ content += '_No comments on this work item._\n';
732
+ }
733
+ else {
734
+ const sorted = [...comments].sort((a, b) => {
735
+ const da = new Date(a.createdDate || a.publishedDate || 0).getTime();
736
+ const db = new Date(b.createdDate || b.publishedDate || 0).getTime();
737
+ return da - db;
738
+ });
739
+ sorted.forEach((comment, index) => {
740
+ content += '---\n\n';
741
+ content += `## Comment #${index + 1}\n`;
742
+ content += `**Author**: ${comment.createdBy?.displayName || 'Unknown'}\n`;
743
+ content += `**Date**: ${comment.createdDate || comment.publishedDate || 'Unknown'}\n\n`;
744
+ content += `${comment.text || comment.content || ''}\n\n`;
745
+ });
746
+ }
747
+ return content;
748
+ }
749
+ // ---------------------------------------------------------------------------
750
+ // Sync revision bump + new-file → synced-file conversion
751
+ // ---------------------------------------------------------------------------
752
+ export function updateSyncRevision(content, newRevision) {
753
+ const updated = content.replace(/lastSyncedRevision:\s*\d+/, `lastSyncedRevision: ${newRevision}`);
754
+ return updated.replace(/lastSyncedAt:\s*[^\n]+/, `lastSyncedAt: ${new Date().toISOString()}`);
755
+ }
754
756
  export function convertNewFileToSynced(content, workItemId, revision, url) {
755
- // Parse the existing content
756
757
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
757
758
  if (!frontmatterMatch) {
758
759
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
759
760
  }
760
- const frontmatterRaw = frontmatterMatch[1];
761
- const frontmatterData = parseFrontmatter(frontmatterRaw);
761
+ const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
762
762
  const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
763
- // Build new frontmatter with id and sync metadata
764
- const newFrontmatter = {
765
- id: workItemId,
766
- title: frontmatterData.title || '',
767
- type: frontmatterData.type || 'User Story',
768
- state: frontmatterData.state || 'New',
769
- url: url,
770
- };
771
- // Copy optional fields
772
- if (frontmatterData.assignedTo)
773
- newFrontmatter.assignedTo = frontmatterData.assignedTo;
774
- if (frontmatterData.storyPoints)
775
- newFrontmatter.storyPoints = frontmatterData.storyPoints;
776
- if (frontmatterData.parent)
777
- newFrontmatter.parent = frontmatterData.parent;
778
- if (frontmatterData.moscow)
779
- newFrontmatter.moscow = frontmatterData.moscow;
780
- if (frontmatterData.tags && frontmatterData.tags.length > 0)
781
- newFrontmatter.tags = frontmatterData.tags;
782
- if (frontmatterData.areaPath)
783
- newFrontmatter.areaPath = frontmatterData.areaPath;
784
- if (frontmatterData.iterationPath)
785
- newFrontmatter.iterationPath = frontmatterData.iterationPath;
786
- // Add sync metadata
787
- newFrontmatter.lastSyncedRevision = revision;
788
- newFrontmatter.lastSyncedAt = new Date().toISOString();
789
- // Remove the "Parent:" and "Project:" note lines from the content
790
- let cleanedContent = contentAfterFrontmatter
763
+ // Rebuild frontmatter with id + sync metadata inserted at the top.
764
+ const newData = { id: workItemId };
765
+ const newOrder = ['id'];
766
+ for (const [key, value] of Object.entries(frontmatterData)) {
767
+ if (key === 'id')
768
+ continue;
769
+ newData[key] = value;
770
+ newOrder.push(key);
771
+ }
772
+ newData.url = url;
773
+ if (!newOrder.includes('url'))
774
+ newOrder.push('url');
775
+ newData.lastSyncedRevision = revision;
776
+ newOrder.push('lastSyncedRevision');
777
+ newData.lastSyncedAt = new Date().toISOString();
778
+ newOrder.push('lastSyncedAt');
779
+ // Strip the transient "Parent:"/"Project:" note lines used only for template readability.
780
+ const cleaned = contentAfterFrontmatter
791
781
  .replace(/>\s*Parent:.*\n/i, '')
792
782
  .replace(/>\s*Project:.*\n/i, '');
793
- return serializeFrontmatter(newFrontmatter) + cleanedContent;
783
+ return serializeFrontmatter(newData, newOrder) + cleaned;
784
+ }
785
+ // ---------------------------------------------------------------------------
786
+ // Internal helpers exposed for tests / sync-service
787
+ // ---------------------------------------------------------------------------
788
+ /**
789
+ * Return the list of large-text (body) refnames that should be HTML-checked
790
+ * for a given work-item type. Driven by the loaded template.
791
+ */
792
+ export function templateBodyRefnamesForType(workItemType) {
793
+ return loadTemplate(workItemType).bodyFields.map((f) => f.refname);
794
794
  }
795
795
  //# sourceMappingURL=markdown-serializer.js.map