@mcp-consultant-tools/azure-devops 30.0.0-beta.9 → 30.0.0
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/azure-devops-client.d.ts.map +1 -1
- package/build/azure-devops-client.js +23 -3
- package/build/azure-devops-client.js.map +1 -1
- package/build/cli/commands/index.d.ts +1 -0
- package/build/cli/commands/index.d.ts.map +1 -1
- package/build/cli/commands/index.js +3 -0
- package/build/cli/commands/index.js.map +1 -1
- package/build/cli/commands/test-commands.d.ts +7 -0
- package/build/cli/commands/test-commands.d.ts.map +1 -0
- package/build/cli/commands/test-commands.js +131 -0
- package/build/cli/commands/test-commands.js.map +1 -0
- package/build/cli/commands/work-item-commands.d.ts.map +1 -1
- package/build/cli/commands/work-item-commands.js +34 -0
- package/build/cli/commands/work-item-commands.js.map +1 -1
- package/build/context-factory.d.ts.map +1 -1
- package/build/context-factory.js +3 -1
- package/build/context-factory.js.map +1 -1
- package/build/schemas.d.ts +13 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +47 -0
- package/build/schemas.js.map +1 -0
- package/build/services/checklist-service.d.ts.map +1 -1
- package/build/services/checklist-service.js +3 -0
- package/build/services/checklist-service.js.map +1 -1
- package/build/services/index.d.ts +1 -0
- package/build/services/index.d.ts.map +1 -1
- package/build/services/index.js +1 -0
- package/build/services/index.js.map +1 -1
- package/build/services/sync-service.d.ts.map +1 -1
- package/build/services/sync-service.js +75 -14
- package/build/services/sync-service.js.map +1 -1
- package/build/services/test-service.d.ts +106 -0
- package/build/services/test-service.d.ts.map +1 -0
- package/build/services/test-service.js +245 -0
- package/build/services/test-service.js.map +1 -0
- package/build/services/work-item-service.d.ts +25 -0
- package/build/services/work-item-service.d.ts.map +1 -1
- package/build/services/work-item-service.js +51 -3
- package/build/services/work-item-service.js.map +1 -1
- package/build/sync/annotation-parser.d.ts +52 -0
- package/build/sync/annotation-parser.d.ts.map +1 -0
- package/build/sync/annotation-parser.js +83 -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-converter.d.ts.map +1 -1
- package/build/sync/html-converter.js +12 -2
- package/build/sync/html-converter.js.map +1 -1
- package/build/sync/html-detection.d.ts +18 -65
- package/build/sync/html-detection.d.ts.map +1 -1
- package/build/sync/html-detection.js +72 -113
- package/build/sync/html-detection.js.map +1 -1
- package/build/sync/image-handler.d.ts +66 -0
- package/build/sync/image-handler.d.ts.map +1 -0
- package/build/sync/image-handler.js +135 -0
- package/build/sync/image-handler.js.map +1 -0
- package/build/sync/image-manifest.d.ts +66 -0
- package/build/sync/image-manifest.d.ts.map +1 -0
- package/build/sync/image-manifest.js +96 -0
- package/build/sync/image-manifest.js.map +1 -0
- package/build/sync/image-sync.d.ts +88 -0
- package/build/sync/image-sync.d.ts.map +1 -0
- package/build/sync/image-sync.js +274 -0
- package/build/sync/image-sync.js.map +1 -0
- package/build/sync/index.d.ts +7 -0
- package/build/sync/index.d.ts.map +1 -1
- package/build/sync/index.js +7 -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 +54 -60
- package/build/sync/markdown-serializer.d.ts.map +1 -1
- package/build/sync/markdown-serializer.js +607 -545
- package/build/sync/markdown-serializer.js.map +1 -1
- package/build/sync/task-serializer.d.ts.map +1 -1
- package/build/sync/task-serializer.js +46 -8
- package/build/sync/task-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/build/tool-examples.d.ts +20 -0
- package/build/tool-examples.d.ts.map +1 -1
- package/build/tool-examples.js +41 -0
- package/build/tool-examples.js.map +1 -1
- package/build/tools/build-tools.d.ts.map +1 -1
- package/build/tools/build-tools.js +7 -6
- package/build/tools/build-tools.js.map +1 -1
- package/build/tools/checklist-tools.d.ts.map +1 -1
- package/build/tools/checklist-tools.js +6 -5
- package/build/tools/checklist-tools.js.map +1 -1
- package/build/tools/index.d.ts +1 -0
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +5 -2
- package/build/tools/index.js.map +1 -1
- package/build/tools/pull-request-tools.d.ts.map +1 -1
- package/build/tools/pull-request-tools.js +15 -14
- package/build/tools/pull-request-tools.js.map +1 -1
- package/build/tools/sync-tools.d.ts.map +1 -1
- package/build/tools/sync-tools.js +9 -8
- package/build/tools/sync-tools.js.map +1 -1
- package/build/tools/test-tools.d.ts +3 -0
- package/build/tools/test-tools.d.ts.map +1 -0
- package/build/tools/test-tools.js +184 -0
- package/build/tools/test-tools.js.map +1 -0
- package/build/tools/variable-group-tools.d.ts.map +1 -1
- package/build/tools/variable-group-tools.js +2 -1
- package/build/tools/variable-group-tools.js.map +1 -1
- package/build/tools/visualize-tools.d.ts.map +1 -1
- package/build/tools/visualize-tools.js +2 -1
- package/build/tools/visualize-tools.js.map +1 -1
- package/build/tools/wiki-tools.d.ts.map +1 -1
- package/build/tools/wiki-tools.js +4 -3
- package/build/tools/wiki-tools.js.map +1 -1
- package/build/tools/work-item-tools.d.ts.map +1 -1
- package/build/tools/work-item-tools.js +42 -11
- package/build/tools/work-item-tools.js.map +1 -1
- package/build/types.d.ts +2 -0
- package/build/types.d.ts.map +1 -1
- 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
|
-
*
|
|
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,655 +91,705 @@ 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 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
|
-
|
|
174
|
-
|
|
175
|
-
return; // HTML format, skip with warning
|
|
212
|
+
else {
|
|
213
|
+
fmData[templateKey] = value;
|
|
176
214
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
*
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
];
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
let
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
350
|
-
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
395
|
-
|
|
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:
|
|
398
|
-
path:
|
|
399
|
-
value:
|
|
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:
|
|
539
|
+
path: `/multilineFieldsFormat/${refname}`,
|
|
405
540
|
value: 'Markdown',
|
|
406
541
|
});
|
|
407
|
-
if (
|
|
408
|
-
convertedFields.push(
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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: `/
|
|
469
|
-
value:
|
|
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
|
|
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
|
-
|
|
515
|
-
|
|
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:
|
|
622
|
+
type: workItemType,
|
|
521
623
|
state: frontmatterData.state || 'New',
|
|
522
624
|
parent: frontmatterData.parent ?? undefined,
|
|
523
|
-
assignedTo:
|
|
524
|
-
storyPoints:
|
|
525
|
-
moscow:
|
|
526
|
-
tags
|
|
527
|
-
areaPath:
|
|
528
|
-
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
//
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
//
|
|
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
|
-
|
|
701
|
+
fmData.parent = parentId;
|
|
702
|
+
if (!fmOrder.includes('parent'))
|
|
703
|
+
fmOrder.splice(1, 0, 'parent'); // after `type`
|
|
662
704
|
}
|
|
663
|
-
let content = serializeFrontmatter(
|
|
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 +=
|
|
708
|
+
content += `\n> Parent: **#${parentId}** - ${parentTitle}\n`;
|
|
679
709
|
}
|
|
680
710
|
content += `> Project: ${project}\n`;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
|
699
|
-
const frontmatterData = parseFrontmatter(frontmatterRaw);
|
|
761
|
+
const frontmatterData = parseFrontmatter(frontmatterMatch[1]);
|
|
700
762
|
const contentAfterFrontmatter = content.slice(frontmatterMatch[0].length);
|
|
701
|
-
//
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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(
|
|
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
|