@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.
- package/build/services/sync-service.d.ts.map +1 -1
- package/build/services/sync-service.js +16 -12
- package/build/services/sync-service.js.map +1 -1
- package/build/sync/annotation-parser.d.ts +49 -0
- package/build/sync/annotation-parser.d.ts.map +1 -0
- package/build/sync/annotation-parser.js +81 -0
- package/build/sync/annotation-parser.js.map +1 -0
- package/build/sync/field-aliases.d.ts +35 -0
- package/build/sync/field-aliases.d.ts.map +1 -0
- package/build/sync/field-aliases.js +76 -0
- package/build/sync/field-aliases.js.map +1 -0
- package/build/sync/html-detection.d.ts +16 -65
- package/build/sync/html-detection.d.ts.map +1 -1
- package/build/sync/html-detection.js +63 -112
- package/build/sync/html-detection.js.map +1 -1
- package/build/sync/image-sync.d.ts +8 -5
- package/build/sync/image-sync.d.ts.map +1 -1
- package/build/sync/image-sync.js +18 -10
- package/build/sync/image-sync.js.map +1 -1
- package/build/sync/index.d.ts +4 -0
- package/build/sync/index.d.ts.map +1 -1
- package/build/sync/index.js +4 -0
- package/build/sync/index.js.map +1 -1
- package/build/sync/legacy-mappings.d.ts +37 -0
- package/build/sync/legacy-mappings.d.ts.map +1 -0
- package/build/sync/legacy-mappings.js +75 -0
- package/build/sync/legacy-mappings.js.map +1 -0
- package/build/sync/markdown-serializer.d.ts +52 -60
- package/build/sync/markdown-serializer.d.ts.map +1 -1
- package/build/sync/markdown-serializer.js +603 -603
- package/build/sync/markdown-serializer.js.map +1 -1
- package/build/sync/template-loader.d.ts +56 -0
- package/build/sync/template-loader.d.ts.map +1 -0
- package/build/sync/template-loader.js +138 -0
- package/build/sync/template-loader.js.map +1 -0
- package/build/sync/templates/bug.md +25 -0
- package/build/sync/templates/epic.md +23 -0
- package/build/sync/templates/feature.md +23 -0
- package/build/sync/templates/task.md +14 -0
- package/build/sync/templates/user-story.md +26 -0
- 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
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
value.
|
|
29
|
-
value
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
169
|
+
* Serialize an ADO work item to an annotated markdown file.
|
|
114
170
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
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
|
-
//
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
*
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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:
|
|
452
|
-
path:
|
|
453
|
-
value:
|
|
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:
|
|
539
|
+
path: `/multilineFieldsFormat/${refname}`,
|
|
458
540
|
value: 'Markdown',
|
|
459
541
|
});
|
|
460
|
-
if (
|
|
461
|
-
convertedFields.push(
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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:
|
|
515
|
-
path: `/fields/${
|
|
516
|
-
value:
|
|
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
|
|
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
|
-
|
|
568
|
-
|
|
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:
|
|
622
|
+
type: workItemType,
|
|
574
623
|
state: frontmatterData.state || 'New',
|
|
575
624
|
parent: frontmatterData.parent ?? undefined,
|
|
576
|
-
assignedTo:
|
|
577
|
-
storyPoints:
|
|
578
|
-
moscow:
|
|
579
|
-
tags
|
|
580
|
-
areaPath:
|
|
581
|
-
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
//
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
//
|
|
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
|
-
|
|
701
|
+
fmData.parent = parentId;
|
|
702
|
+
if (!fmOrder.includes('parent'))
|
|
703
|
+
fmOrder.splice(1, 0, 'parent'); // after `type`
|
|
724
704
|
}
|
|
725
|
-
let content = serializeFrontmatter(
|
|
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 +=
|
|
708
|
+
content += `\n> Parent: **#${parentId}** - ${parentTitle}\n`;
|
|
741
709
|
}
|
|
742
710
|
content += `> Project: ${project}\n`;
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
752
|
-
|
|
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
|
|
761
|
-
const frontmatterData = parseFrontmatter(frontmatterRaw);
|
|
761
|
+
const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
|
|
762
762
|
const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
|
|
763
|
-
//
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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(
|
|
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
|