@mcp-consultant-tools/azure-devops 30.0.0-beta.9 → 31.0.0-beta.2

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 (129) hide show
  1. package/build/azure-devops-client.d.ts.map +1 -1
  2. package/build/azure-devops-client.js +23 -3
  3. package/build/azure-devops-client.js.map +1 -1
  4. package/build/cli/commands/index.d.ts +1 -0
  5. package/build/cli/commands/index.d.ts.map +1 -1
  6. package/build/cli/commands/index.js +3 -0
  7. package/build/cli/commands/index.js.map +1 -1
  8. package/build/cli/commands/test-commands.d.ts +7 -0
  9. package/build/cli/commands/test-commands.d.ts.map +1 -0
  10. package/build/cli/commands/test-commands.js +131 -0
  11. package/build/cli/commands/test-commands.js.map +1 -0
  12. package/build/cli/commands/work-item-commands.d.ts.map +1 -1
  13. package/build/cli/commands/work-item-commands.js +34 -0
  14. package/build/cli/commands/work-item-commands.js.map +1 -1
  15. package/build/context-factory.d.ts.map +1 -1
  16. package/build/context-factory.js +10 -4
  17. package/build/context-factory.js.map +1 -1
  18. package/build/schemas.d.ts +13 -0
  19. package/build/schemas.d.ts.map +1 -0
  20. package/build/schemas.js +47 -0
  21. package/build/schemas.js.map +1 -0
  22. package/build/services/checklist-service.d.ts.map +1 -1
  23. package/build/services/checklist-service.js +3 -0
  24. package/build/services/checklist-service.js.map +1 -1
  25. package/build/services/index.d.ts +1 -0
  26. package/build/services/index.d.ts.map +1 -1
  27. package/build/services/index.js +1 -0
  28. package/build/services/index.js.map +1 -1
  29. package/build/services/sync-service.d.ts.map +1 -1
  30. package/build/services/sync-service.js +75 -14
  31. package/build/services/sync-service.js.map +1 -1
  32. package/build/services/test-service.d.ts +106 -0
  33. package/build/services/test-service.d.ts.map +1 -0
  34. package/build/services/test-service.js +245 -0
  35. package/build/services/test-service.js.map +1 -0
  36. package/build/services/work-item-service.d.ts +29 -1
  37. package/build/services/work-item-service.d.ts.map +1 -1
  38. package/build/services/work-item-service.js +66 -6
  39. package/build/services/work-item-service.js.map +1 -1
  40. package/build/sync/annotation-parser.d.ts +52 -0
  41. package/build/sync/annotation-parser.d.ts.map +1 -0
  42. package/build/sync/annotation-parser.js +83 -0
  43. package/build/sync/annotation-parser.js.map +1 -0
  44. package/build/sync/field-aliases.d.ts +35 -0
  45. package/build/sync/field-aliases.d.ts.map +1 -0
  46. package/build/sync/field-aliases.js +76 -0
  47. package/build/sync/field-aliases.js.map +1 -0
  48. package/build/sync/html-converter.d.ts.map +1 -1
  49. package/build/sync/html-converter.js +12 -2
  50. package/build/sync/html-converter.js.map +1 -1
  51. package/build/sync/html-detection.d.ts +18 -65
  52. package/build/sync/html-detection.d.ts.map +1 -1
  53. package/build/sync/html-detection.js +72 -113
  54. package/build/sync/html-detection.js.map +1 -1
  55. package/build/sync/image-handler.d.ts +66 -0
  56. package/build/sync/image-handler.d.ts.map +1 -0
  57. package/build/sync/image-handler.js +135 -0
  58. package/build/sync/image-handler.js.map +1 -0
  59. package/build/sync/image-manifest.d.ts +66 -0
  60. package/build/sync/image-manifest.d.ts.map +1 -0
  61. package/build/sync/image-manifest.js +96 -0
  62. package/build/sync/image-manifest.js.map +1 -0
  63. package/build/sync/image-sync.d.ts +88 -0
  64. package/build/sync/image-sync.d.ts.map +1 -0
  65. package/build/sync/image-sync.js +274 -0
  66. package/build/sync/image-sync.js.map +1 -0
  67. package/build/sync/index.d.ts +7 -0
  68. package/build/sync/index.d.ts.map +1 -1
  69. package/build/sync/index.js +7 -0
  70. package/build/sync/index.js.map +1 -1
  71. package/build/sync/legacy-mappings.d.ts +37 -0
  72. package/build/sync/legacy-mappings.d.ts.map +1 -0
  73. package/build/sync/legacy-mappings.js +75 -0
  74. package/build/sync/legacy-mappings.js.map +1 -0
  75. package/build/sync/markdown-serializer.d.ts +54 -60
  76. package/build/sync/markdown-serializer.d.ts.map +1 -1
  77. package/build/sync/markdown-serializer.js +607 -545
  78. package/build/sync/markdown-serializer.js.map +1 -1
  79. package/build/sync/task-serializer.d.ts.map +1 -1
  80. package/build/sync/task-serializer.js +46 -8
  81. package/build/sync/task-serializer.js.map +1 -1
  82. package/build/sync/template-loader.d.ts +56 -0
  83. package/build/sync/template-loader.d.ts.map +1 -0
  84. package/build/sync/template-loader.js +138 -0
  85. package/build/sync/template-loader.js.map +1 -0
  86. package/build/sync/templates/bug.md +25 -0
  87. package/build/sync/templates/epic.md +23 -0
  88. package/build/sync/templates/feature.md +23 -0
  89. package/build/sync/templates/task.md +14 -0
  90. package/build/sync/templates/user-story.md +26 -0
  91. package/build/tool-examples.d.ts +20 -0
  92. package/build/tool-examples.d.ts.map +1 -1
  93. package/build/tool-examples.js +41 -0
  94. package/build/tool-examples.js.map +1 -1
  95. package/build/tools/build-tools.d.ts.map +1 -1
  96. package/build/tools/build-tools.js +7 -6
  97. package/build/tools/build-tools.js.map +1 -1
  98. package/build/tools/checklist-tools.d.ts.map +1 -1
  99. package/build/tools/checklist-tools.js +6 -5
  100. package/build/tools/checklist-tools.js.map +1 -1
  101. package/build/tools/index.d.ts +1 -0
  102. package/build/tools/index.d.ts.map +1 -1
  103. package/build/tools/index.js +5 -2
  104. package/build/tools/index.js.map +1 -1
  105. package/build/tools/pull-request-tools.d.ts.map +1 -1
  106. package/build/tools/pull-request-tools.js +15 -14
  107. package/build/tools/pull-request-tools.js.map +1 -1
  108. package/build/tools/sync-tools.d.ts.map +1 -1
  109. package/build/tools/sync-tools.js +9 -8
  110. package/build/tools/sync-tools.js.map +1 -1
  111. package/build/tools/test-tools.d.ts +3 -0
  112. package/build/tools/test-tools.d.ts.map +1 -0
  113. package/build/tools/test-tools.js +184 -0
  114. package/build/tools/test-tools.js.map +1 -0
  115. package/build/tools/variable-group-tools.d.ts.map +1 -1
  116. package/build/tools/variable-group-tools.js +2 -1
  117. package/build/tools/variable-group-tools.js.map +1 -1
  118. package/build/tools/visualize-tools.d.ts.map +1 -1
  119. package/build/tools/visualize-tools.js +2 -1
  120. package/build/tools/visualize-tools.js.map +1 -1
  121. package/build/tools/wiki-tools.d.ts.map +1 -1
  122. package/build/tools/wiki-tools.js +4 -3
  123. package/build/tools/wiki-tools.js.map +1 -1
  124. package/build/tools/work-item-tools.d.ts.map +1 -1
  125. package/build/tools/work-item-tools.js +42 -11
  126. package/build/tools/work-item-tools.js.map +1 -1
  127. package/build/types.d.ts +2 -0
  128. package/build/types.d.ts.map +1 -1
  129. package/package.json +3 -3
@@ -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,655 +91,705 @@ 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 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
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] ?? '';
172
211
  }
173
- if (isHtmlContent(fieldContent)) {
174
- skippedFields.push(`${displayName} (HTML)`);
175
- return; // HTML format, skip with warning
212
+ else {
213
+ fmData[templateKey] = value;
176
214
  }
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');
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;
241
+ }
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);
184
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
+ }
185
278
  return { content, skippedFields };
186
279
  }
187
280
  /**
188
- * 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".
189
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.
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
+ // ---------------------------------------------------------------------------
190
357
  export function parseWorkItemMarkdown(content) {
191
- // Extract frontmatter between ---
192
358
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
193
359
  if (!frontmatterMatch) {
194
360
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
195
361
  }
196
362
  const frontmatterRaw = frontmatterMatch[1];
197
363
  const frontmatterData = parseFrontmatter(frontmatterRaw);
198
- // Validate required fields
199
364
  if (!frontmatterData.id || typeof frontmatterData.id !== 'number') {
200
365
  throw new Error('Invalid work item markdown file: missing or invalid "id" in frontmatter');
201
366
  }
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 level-1 heading or end of file
224
- // Note: \n#\s+ only matches level-1 headings (# Foo), not ## or ### — those are preserved as content
225
- // [^\S\n]* matches horizontal whitespace only (not \n) to avoid consuming the blank line after heading
226
- const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}[^\\S\\n]*\\n([\\s\\S]*?)(?=\\n#\\s+|$)`, 'i');
227
- const match = contentAfterFrontmatter.match(regex);
228
- if (!match)
229
- return undefined;
230
- // Strip trailing --- separators (visual formatting between sections, not content)
231
- const sectionContent = match[1].trim().replace(/(\n---\s*)+$/, '').trim();
232
- // Return undefined if it's just the placeholder text
233
- if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
234
- return undefined;
235
- }
236
- return sectionContent || undefined;
237
- };
238
- // Extract primary sections
239
- let description = extractSection('Description');
240
- if (description === undefined) {
241
- // Only use fallback if there's genuinely no # Description heading at all
242
- const hasDescriptionHeading = /(?:^|\n)#\s+Description[^\S\n]*\n/i.test(contentAfterFrontmatter);
243
- if (!hasDescriptionHeading) {
244
- description = extractFallbackDescription(contentAfterFrontmatter);
245
- }
246
- else {
247
- 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
+ }
248
395
  }
396
+ localOnlySections = parsed.localOnly;
249
397
  }
250
- if (description === '_No description provided._') {
251
- description = '';
252
- }
253
- let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
254
- if (acceptanceCriteria === '_No acceptance criteria provided._') {
255
- acceptanceCriteria = '';
256
- }
257
- // Extract additional fields (optional)
258
- const additionalFields = {};
259
- const howToTest = extractSection('How to Test');
260
- if (howToTest)
261
- additionalFields.howToTest = howToTest;
262
- const predeploymentSteps = extractSection('Predeployment Steps');
263
- if (predeploymentSteps)
264
- additionalFields.predeploymentSteps = predeploymentSteps;
265
- const postdeploymentSteps = extractSection('Postdeployment Steps');
266
- if (postdeploymentSteps)
267
- additionalFields.postdeploymentSteps = postdeploymentSteps;
268
- const deploymentInformation = extractSection('Deployment Information');
269
- if (deploymentInformation)
270
- 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
+ };
271
413
  return {
272
414
  frontmatter,
415
+ fieldMap,
416
+ bodyFieldMap,
417
+ localOnlySections,
418
+ workItemType,
273
419
  description,
420
+ reproSteps,
274
421
  acceptanceCriteria,
275
422
  additionalFields,
276
423
  rawContent: content,
277
424
  };
278
425
  }
279
426
  /**
280
- * Fallback description extraction for files without a # Description heading.
281
- * Returns all content before the first recognized section heading, with
282
- * HTML template comments stripped.
427
+ * Parse legacy `# Heading` sections using the legacy-mappings fallback.
283
428
  */
284
- const RECOGNIZED_SECTIONS = [
285
- 'Acceptance Criteria',
286
- 'How to Test',
287
- 'Predeployment Steps',
288
- 'Postdeployment Steps',
289
- 'Deployment Information',
290
- ];
291
- function extractFallbackDescription(contentAfterFrontmatter) {
292
- // Find the earliest recognized section heading
293
- let earliestIndex = contentAfterFrontmatter.length;
294
- for (const section of RECOGNIZED_SECTIONS) {
295
- const regex = new RegExp(`\\n#\\s+${escapeRegex(section)}\\s*\\n`, 'i');
296
- const match = contentAfterFrontmatter.match(regex);
297
- if (match && match.index !== undefined && match.index < earliestIndex) {
298
- 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 });
299
458
  }
300
459
  }
301
- let fallback = contentAfterFrontmatter.slice(0, earliestIndex);
302
- // Strip only template-generated HTML comments (frontmatter hints), preserve user comments
303
- fallback = fallback.replace(/<!--\s*Optional frontmatter fields[\s\S]*?-->/g, '');
304
- // Strip trailing --- separators
305
- fallback = fallback.replace(/(\n---\s*)+$/, '');
306
- return fallback.trim();
460
+ return out;
307
461
  }
308
- /**
309
- * Escape special regex characters in a string
310
- */
311
- function escapeRegex(str) {
312
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
462
+ function stripLegacySeparators(content) {
463
+ return content.replace(/(\n\s*---\s*)+\s*$/, '').replace(/^\s*(---\s*\n)+/, '');
313
464
  }
314
- /**
315
- * Convert ADO comments to a read-only markdown file
316
- */
317
- export function commentsToMarkdown(workItem, comments) {
318
- const fields = workItem.fields || {};
319
- const frontmatter = {
320
- id: workItem.id,
321
- title: fields['System.Title'] || '',
322
- commentCount: comments.length,
323
- 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 || '',
324
492
  };
325
- let content = serializeFrontmatter(frontmatter);
326
- content += '\n\n# Comments\n\n';
327
- content += '> **NOTE**: This file is read-only. Comments cannot be pushed back to ADO.\n\n';
328
- if (comments.length === 0) {
329
- content += '_No comments on this work item._\n';
330
- }
331
- else {
332
- // Sort comments by date (oldest first)
333
- const sortedComments = [...comments].sort((a, b) => {
334
- const dateA = new Date(a.createdDate || a.publishedDate || 0);
335
- const dateB = new Date(b.createdDate || b.publishedDate || 0);
336
- return dateA.getTime() - dateB.getTime();
337
- });
338
- sortedComments.forEach((comment, index) => {
339
- content += '---\n\n';
340
- content += `## Comment #${index + 1}\n`;
341
- content += `**Author**: ${comment.createdBy?.displayName || 'Unknown'}\n`;
342
- content += `**Date**: ${comment.createdDate || comment.publishedDate || 'Unknown'}\n\n`;
343
- content += `${comment.text || comment.content || ''}\n\n`;
344
- });
345
- }
346
- return content;
347
493
  }
348
- /**
349
- * Build ADO patch operations from parsed markdown changes
350
- * Only updates fields that have actually changed.
351
- * Auto-converts HTML fields to markdown format unless skipAutoConvert is true.
352
- *
353
- * @param parsed - Parsed work item file
354
- * @param currentWorkItem - Current work item from ADO
355
- * @param skipAutoConvert - Skip automatic HTML-to-markdown conversion (default: false)
356
- */
494
+ // ---------------------------------------------------------------------------
495
+ // UP: build ADO patch operations
496
+ // ---------------------------------------------------------------------------
357
497
  export function buildPatchOperations(parsed, currentWorkItem, skipAutoConvert = false) {
358
498
  const operations = [];
359
499
  const skippedFields = [];
360
500
  const convertedFields = [];
361
501
  const currentFields = currentWorkItem.fields || {};
362
- const fieldConfig = getAdditionalFieldConfig();
363
- // Check Title
364
- const currentTitle = currentFields['System.Title'] || '';
365
- if (parsed.frontmatter.title !== currentTitle) {
366
- operations.push({
367
- op: 'replace',
368
- path: '/fields/System.Title',
369
- value: parsed.frontmatter.title,
370
- });
371
- }
372
- // Check Story Points
373
- const currentStoryPoints = currentFields['Microsoft.VSTS.Scheduling.StoryPoints'];
374
- const localStoryPoints = parsed.frontmatter.storyPoints;
375
- if (localStoryPoints !== undefined && localStoryPoints !== currentStoryPoints) {
376
- operations.push({
377
- op: currentStoryPoints !== undefined ? 'replace' : 'add',
378
- path: '/fields/Microsoft.VSTS.Scheduling.StoryPoints',
379
- value: localStoryPoints,
380
- });
381
- }
382
- // Check Description - always try to set markdown format when writing
383
- const currentDescription = currentFields['System.Description'] || '';
384
- const descriptionIsHtml = isHtmlContent(currentDescription);
385
- if (descriptionIsHtml && skipAutoConvert) {
386
- // Skip HTML field when skipAutoConvert is true
387
- skippedFields.push('Description (HTML in ADO - skipAutoConvert=true)');
388
- }
389
- else if (parsed.description !== currentDescription || descriptionIsHtml) {
390
- // Safety guard: don't push empty content when ADO has existing content
391
- if (!parsed.description && currentDescription) {
392
- 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;
393
517
  }
394
- else {
395
- // Write field value
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;
526
+ }
527
+ if (localIsEmpty && !currentString)
528
+ continue;
529
+ // Compare stringified body content.
530
+ if (String(localValue) === currentString && !isHtmlField)
531
+ continue;
396
532
  operations.push({
397
- op: currentDescription ? 'replace' : 'add',
398
- path: '/fields/System.Description',
399
- value: parsed.description,
533
+ op: currentString ? 'replace' : 'add',
534
+ path: `/fields/${refname}`,
535
+ value: String(localValue),
400
536
  });
401
- // Always set markdown format when writing to ensure ADO renders correctly
402
537
  operations.push({
403
538
  op: 'add',
404
- path: '/multilineFieldsFormat/System.Description',
539
+ path: `/multilineFieldsFormat/${refname}`,
405
540
  value: 'Markdown',
406
541
  });
407
- if (descriptionIsHtml) {
408
- convertedFields.push('Description');
409
- }
410
- }
411
- }
412
- // Check Acceptance Criteria - always try to set markdown format when writing
413
- const currentAC = currentFields['Microsoft.VSTS.Common.AcceptanceCriteria'] || '';
414
- const acIsHtml = isHtmlContent(currentAC);
415
- if (acIsHtml && skipAutoConvert) {
416
- // Skip HTML field when skipAutoConvert is true
417
- skippedFields.push('Acceptance Criteria (HTML in ADO - skipAutoConvert=true)');
418
- }
419
- else if (parsed.acceptanceCriteria !== currentAC || acIsHtml) {
420
- // Safety guard: don't push empty content when ADO has existing content
421
- if (!parsed.acceptanceCriteria && currentAC) {
422
- skippedFields.push('Acceptance Criteria (local file has no content — skipping to prevent data loss)');
542
+ if (isHtmlField)
543
+ convertedFields.push(refname);
423
544
  }
424
545
  else {
425
- // Write field value
426
- operations.push({
427
- op: currentAC ? 'replace' : 'add',
428
- path: '/fields/Microsoft.VSTS.Common.AcceptanceCriteria',
429
- value: parsed.acceptanceCriteria,
430
- });
431
- // Always set markdown format when writing to ensure ADO renders correctly
432
- operations.push({
433
- op: 'add',
434
- path: '/multilineFieldsFormat/Microsoft.VSTS.Common.AcceptanceCriteria',
435
- value: 'Markdown',
436
- });
437
- if (acIsHtml) {
438
- convertedFields.push('Acceptance Criteria');
546
+ if (localIsEmpty && (currentRaw === undefined || currentRaw === '' || currentRaw === null)) {
547
+ continue;
439
548
  }
440
- }
441
- }
442
- // Helper to check and add patch operation for additional field
443
- // Always tries to set markdown format when writing to a field
444
- const checkAdditionalField = (localValue, fieldName, displayName) => {
445
- const currentValue = currentFields[fieldName] || '';
446
- const localContent = localValue || '';
447
- const fieldIsHtml = currentValue && isHtmlContent(currentValue);
448
- if (fieldIsHtml && skipAutoConvert) {
449
- // Skip HTML field when skipAutoConvert is true
450
- skippedFields.push(`${displayName} (HTML in ADO - skipAutoConvert=true)`);
451
- return;
452
- }
453
- if (localContent !== currentValue || fieldIsHtml) {
454
- // Safety guard: don't push empty content when ADO has existing content
455
- if (!localContent && currentValue) {
456
- skippedFields.push(`${displayName} (local file has no content — skipping to prevent data loss)`);
457
- 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;
458
556
  }
459
- // Write field value
460
- operations.push({
461
- op: currentValue ? 'replace' : 'add',
462
- path: `/fields/${fieldName}`,
463
- value: localContent,
464
- });
465
- // Always set markdown format when writing to ensure ADO renders correctly
466
557
  operations.push({
467
- op: 'add',
468
- path: `/multilineFieldsFormat/${fieldName}`,
469
- value: 'Markdown',
558
+ op: currentRaw !== undefined && currentRaw !== null && currentRaw !== '' ? 'replace' : 'add',
559
+ path: `/fields/${refname}`,
560
+ value: frontmatterValueToAdo(refname, localValue),
470
561
  });
471
- if (fieldIsHtml) {
472
- convertedFields.push(displayName);
473
- }
474
562
  }
475
- };
476
- // Check additional fields
477
- checkAdditionalField(parsed.additionalFields.howToTest, fieldConfig.howToTest, ADDITIONAL_FIELD_DISPLAY_NAMES.howToTest);
478
- checkAdditionalField(parsed.additionalFields.predeploymentSteps, fieldConfig.predeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.predeploymentSteps);
479
- checkAdditionalField(parsed.additionalFields.postdeploymentSteps, fieldConfig.postdeploymentSteps, ADDITIONAL_FIELD_DISPLAY_NAMES.postdeploymentSteps);
480
- checkAdditionalField(parsed.additionalFields.deploymentInformation, fieldConfig.deploymentInformation, ADDITIONAL_FIELD_DISPLAY_NAMES.deploymentInformation);
563
+ }
481
564
  return { operations, skippedFields, convertedFields };
482
565
  }
483
- /**
484
- * Update the lastSyncedRevision in a markdown file content
485
- */
486
- export function updateSyncRevision(content, newRevision) {
487
- // Update lastSyncedRevision in frontmatter
488
- const updatedContent = content.replace(/lastSyncedRevision:\s*\d+/, `lastSyncedRevision: ${newRevision}`);
489
- // Update lastSyncedAt timestamp
490
- return updatedContent.replace(/lastSyncedAt:\s*[^\n]+/, `lastSyncedAt: ${new Date().toISOString()}`);
491
- }
492
- /**
493
- * Check if a work item frontmatter indicates a new (not yet created) work item
494
- * New work items don't have an 'id' field
495
- */
496
566
  export function isNewWorkItem(frontmatter) {
497
567
  return !frontmatter.id || typeof frontmatter.id !== 'number';
498
568
  }
499
- /**
500
- * Parse a markdown file for a NEW work item (no id required)
501
- */
502
569
  export function parseNewWorkItemMarkdown(content) {
503
- // Extract frontmatter between ---
504
570
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
505
571
  if (!frontmatterMatch) {
506
572
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
507
573
  }
508
- const frontmatterRaw = frontmatterMatch[1];
509
- const frontmatterData = parseFrontmatter(frontmatterRaw);
510
- // Validate required fields for new work items
574
+ const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
511
575
  if (!frontmatterData.title || typeof frontmatterData.title !== 'string') {
512
576
  throw new Error('New work item files require a "title" field');
513
577
  }
514
- // Validate parent is a number if present
515
- 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') {
516
581
  throw new Error('The "parent" field must be a number (work item ID)');
517
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;
518
620
  const frontmatter = {
519
621
  title: frontmatterData.title,
520
- type: frontmatterData.type || 'User Story',
622
+ type: workItemType,
521
623
  state: frontmatterData.state || 'New',
522
624
  parent: frontmatterData.parent ?? undefined,
523
- assignedTo: frontmatterData.assignedTo,
524
- storyPoints: frontmatterData.storyPoints,
525
- moscow: frontmatterData.moscow,
526
- tags: frontmatterData.tags,
527
- areaPath: frontmatterData.areaPath,
528
- iterationPath: frontmatterData.iterationPath,
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'],
529
631
  };
530
- // Extract content after frontmatter
531
- const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
532
- // Helper to extract a section by heading (reuse from parseWorkItemMarkdown)
533
- const extractSection = (sectionName) => {
534
- const regex = new RegExp(`#\\s+${escapeRegex(sectionName)}[^\\S\\n]*\\n([\\s\\S]*?)(?=\\n#\\s+|$)`, 'i');
535
- const match = contentAfterFrontmatter.match(regex);
536
- if (!match)
537
- return undefined;
538
- // Strip trailing --- separators (visual formatting between sections, not content)
539
- const sectionContent = match[1].trim().replace(/(\n---\s*)+$/, '').trim();
540
- if (sectionContent === `_No ${sectionName.toLowerCase()} provided._`) {
541
- return undefined;
542
- }
543
- return sectionContent || undefined;
544
- };
545
- // Extract primary sections
546
- let description = extractSection('Description') || '';
547
- if (description === '_No description provided._' || description === '[Your description here]') {
548
- description = '';
549
- }
550
- let acceptanceCriteria = extractSection('Acceptance Criteria') || '';
551
- if (acceptanceCriteria === '_No acceptance criteria provided._' || acceptanceCriteria === '[Your acceptance criteria here]') {
552
- acceptanceCriteria = '';
553
- }
554
- // Extract additional fields (optional)
555
- const additionalFields = {};
556
- const howToTest = extractSection('How to Test');
557
- if (howToTest)
558
- additionalFields.howToTest = howToTest;
559
- const predeploymentSteps = extractSection('Predeployment Steps');
560
- if (predeploymentSteps)
561
- additionalFields.predeploymentSteps = predeploymentSteps;
562
- const postdeploymentSteps = extractSection('Postdeployment Steps');
563
- if (postdeploymentSteps)
564
- additionalFields.postdeploymentSteps = postdeploymentSteps;
565
- const deploymentInformation = extractSection('Deployment Information');
566
- if (deploymentInformation)
567
- additionalFields.deploymentInformation = deploymentInformation;
568
632
  return {
569
633
  frontmatter,
570
- description,
571
- acceptanceCriteria,
572
- 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
+ },
573
647
  rawContent: content,
574
648
  };
575
649
  }
576
- /**
577
- * Build ADO fields object for creating a new work item
578
- * Inherits areaPath and iterationPath from parent work item when available
579
- *
580
- * Returns fields split into standard (safe for creation) and custom (require
581
- * a follow-up update) because ADO rejects custom fields during work item creation.
582
- */
583
650
  export function buildNewWorkItemFields(parsed, parentWorkItem) {
584
651
  const parentFields = parentWorkItem?.fields || {};
585
- const fieldConfig = getAdditionalFieldConfig();
586
- const standardFields = {
587
- 'System.Title': parsed.frontmatter.title,
588
- 'System.State': parsed.frontmatter.state || 'New',
589
- };
590
- // Set description if provided
591
- if (parsed.description) {
592
- standardFields['System.Description'] = parsed.description;
593
- }
594
- // Set acceptance criteria if provided
595
- if (parsed.acceptanceCriteria) {
596
- standardFields['Microsoft.VSTS.Common.AcceptanceCriteria'] = parsed.acceptanceCriteria;
597
- }
598
- // Story points - only set if it's a valid number (including 0)
599
- // YAML may parse empty value as null/undefined, or string if quoted
600
- // Using 'any' cast because YAML parser may return types not matching interface
601
- const storyPointsValue = parsed.frontmatter.storyPoints;
602
- if (storyPointsValue !== undefined && storyPointsValue !== null && storyPointsValue !== '') {
603
- const storyPoints = Number(storyPointsValue);
604
- if (!isNaN(storyPoints)) {
605
- standardFields['Microsoft.VSTS.Scheduling.StoryPoints'] = storyPoints;
606
- }
607
- }
608
- // Tags - ensure it's an array with items before joining
609
- if (Array.isArray(parsed.frontmatter.tags) && parsed.frontmatter.tags.length > 0) {
610
- 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);
611
663
  }
612
- // Inherit areaPath from parent if not specified
613
- if (parsed.frontmatter.areaPath) {
614
- 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;
615
670
  }
616
- else if (parentFields['System.AreaPath']) {
671
+ // Inherit area/iteration path from parent when not provided.
672
+ if (!standardFields['System.AreaPath'] && parentFields['System.AreaPath']) {
617
673
  standardFields['System.AreaPath'] = parentFields['System.AreaPath'];
618
674
  }
619
- // Inherit iterationPath from parent if not specified
620
- if (parsed.frontmatter.iterationPath) {
621
- standardFields['System.IterationPath'] = parsed.frontmatter.iterationPath;
622
- }
623
- else if (parentFields['System.IterationPath']) {
675
+ if (!standardFields['System.IterationPath'] && parentFields['System.IterationPath']) {
624
676
  standardFields['System.IterationPath'] = parentFields['System.IterationPath'];
625
677
  }
626
- // Custom fields — NOT valid during creation, must be set via follow-up update
627
- const customFields = {};
628
- // MoSCoW (custom field) - only set if non-empty
629
- const moscowValue = parsed.frontmatter.moscow;
630
- if (moscowValue && String(moscowValue).trim() !== '') {
631
- customFields['Custom.MoSCoW'] = moscowValue;
632
- }
633
- // Additional custom fields
634
- if (parsed.additionalFields.howToTest) {
635
- customFields[fieldConfig.howToTest] = parsed.additionalFields.howToTest;
636
- }
637
- if (parsed.additionalFields.predeploymentSteps) {
638
- customFields[fieldConfig.predeploymentSteps] = parsed.additionalFields.predeploymentSteps;
639
- }
640
- if (parsed.additionalFields.postdeploymentSteps) {
641
- customFields[fieldConfig.postdeploymentSteps] = parsed.additionalFields.postdeploymentSteps;
642
- }
643
- if (parsed.additionalFields.deploymentInformation) {
644
- customFields[fieldConfig.deploymentInformation] = parsed.additionalFields.deploymentInformation;
645
- }
646
678
  return { standardFields, customFields };
647
679
  }
648
- /**
649
- * Generate a new work item template markdown file
650
- * When parentId is undefined, creates a standalone work item template
651
- */
680
+ function isStandardRefname(refname) {
681
+ return refname.startsWith('System.') || refname.startsWith('Microsoft.VSTS.');
682
+ }
683
+ // ---------------------------------------------------------------------------
684
+ // Template generation (new work item file scaffold)
685
+ // ---------------------------------------------------------------------------
652
686
  export function generateNewWorkItemTemplate(parentId, parentTitle, project, workItemType = 'User Story') {
653
- // Only include required fields - optional fields omitted to avoid empty value issues
654
- const frontmatter = {
655
- title: `New ${workItemType} Title`,
656
- type: workItemType,
657
- state: 'New',
658
- };
659
- // 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
+ }
660
700
  if (parentId !== undefined) {
661
- frontmatter.parent = parentId;
701
+ fmData.parent = parentId;
702
+ if (!fmOrder.includes('parent'))
703
+ fmOrder.splice(1, 0, 'parent'); // after `type`
662
704
  }
663
- let content = serializeFrontmatter(frontmatter);
705
+ let content = serializeFrontmatter(fmData, fmOrder);
664
706
  content += '\n';
665
- content += `<!-- Optional frontmatter fields (add to YAML above if needed):\n`;
666
- if (parentId === undefined) {
667
- content += `parent: 12345\n`;
668
- }
669
- content += `storyPoints: 3\n`;
670
- content += `moscow: Should Have\n`;
671
- content += `areaPath: Project\\Area\n`;
672
- content += `iterationPath: Project\\Sprint 1\n`;
673
- content += `tags:\n`;
674
- content += `- tag1\n`;
675
- content += `- tag2\n`;
676
- content += `-->\n\n`;
677
707
  if (parentId !== undefined) {
678
- content += `> Parent: **#${parentId}** - ${parentTitle}\n`;
708
+ content += `\n> Parent: **#${parentId}** - ${parentTitle}\n`;
679
709
  }
680
710
  content += `> Project: ${project}\n`;
681
- content += '\n# Description\n\n';
682
- content += '[Your description here]\n\n';
683
- content += '---\n\n';
684
- content += '# Acceptance Criteria\n\n';
685
- content += '[Your acceptance criteria here]\n';
711
+ for (const bodyField of template.bodyFields) {
712
+ content += '\n' + serializeAnnotatedSection(bodyField.heading, bodyField.refname, '');
713
+ }
686
714
  return content;
687
715
  }
688
- /**
689
- * Update a new work item file after creation with the assigned ID
690
- * Converts it from a "new" file to a synced file with proper frontmatter
691
- */
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
+ }
692
756
  export function convertNewFileToSynced(content, workItemId, revision, url) {
693
- // Parse the existing content
694
757
  const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
695
758
  if (!frontmatterMatch) {
696
759
  throw new Error('Invalid work item markdown file: missing YAML frontmatter');
697
760
  }
698
- const frontmatterRaw = frontmatterMatch[1];
699
- const frontmatterData = parseFrontmatter(frontmatterRaw);
761
+ const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
700
762
  const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
701
- // Build new frontmatter with id and sync metadata
702
- const newFrontmatter = {
703
- id: workItemId,
704
- title: frontmatterData.title || '',
705
- type: frontmatterData.type || 'User Story',
706
- state: frontmatterData.state || 'New',
707
- url: url,
708
- };
709
- // Copy optional fields
710
- if (frontmatterData.assignedTo)
711
- newFrontmatter.assignedTo = frontmatterData.assignedTo;
712
- if (frontmatterData.storyPoints)
713
- newFrontmatter.storyPoints = frontmatterData.storyPoints;
714
- if (frontmatterData.parent)
715
- newFrontmatter.parent = frontmatterData.parent;
716
- if (frontmatterData.moscow)
717
- newFrontmatter.moscow = frontmatterData.moscow;
718
- if (frontmatterData.tags && frontmatterData.tags.length > 0)
719
- newFrontmatter.tags = frontmatterData.tags;
720
- if (frontmatterData.areaPath)
721
- newFrontmatter.areaPath = frontmatterData.areaPath;
722
- if (frontmatterData.iterationPath)
723
- newFrontmatter.iterationPath = frontmatterData.iterationPath;
724
- // Add sync metadata
725
- newFrontmatter.lastSyncedRevision = revision;
726
- newFrontmatter.lastSyncedAt = new Date().toISOString();
727
- // Remove the "Parent:" and "Project:" note lines from the content
728
- 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
729
781
  .replace(/>\s*Parent:.*\n/i, '')
730
782
  .replace(/>\s*Project:.*\n/i, '');
731
- 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);
732
794
  }
733
795
  //# sourceMappingURL=markdown-serializer.js.map