@elisra-devops/docgen-data-provider 1.137.1 → 1.139.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/bin/modules/TicketsDataProvider.d.ts +24 -0
- package/bin/modules/TicketsDataProvider.js +138 -19
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +204 -0
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +206 -39
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +238 -0
|
@@ -10,6 +10,7 @@ export default class TicketsDataProvider {
|
|
|
10
10
|
token: string;
|
|
11
11
|
queriesList: Array<any>;
|
|
12
12
|
private limit;
|
|
13
|
+
private historicalFieldsOverride;
|
|
13
14
|
constructor(orgUrl: string, token: string);
|
|
14
15
|
/**
|
|
15
16
|
* Extracts the Azure DevOps project name from a WIQL URL.
|
|
@@ -201,6 +202,25 @@ export default class TicketsDataProvider {
|
|
|
201
202
|
}>;
|
|
202
203
|
private normalizeHistoricalAsOf;
|
|
203
204
|
private normalizeHistoricalCompareValue;
|
|
205
|
+
/**
|
|
206
|
+
* Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
|
|
207
|
+
* so the report renderer can clean/render it faithfully. Equality between two snapshots must go
|
|
208
|
+
* through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
|
|
209
|
+
*/
|
|
210
|
+
private rawHistoricalRichText;
|
|
211
|
+
/**
|
|
212
|
+
* Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
|
|
213
|
+
* whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
|
|
214
|
+
* summary counts are unaffected by storing the raw value for rendering.
|
|
215
|
+
*/
|
|
216
|
+
private canonicalizeHistoricalRichTextForCompare;
|
|
217
|
+
/**
|
|
218
|
+
* Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
|
|
219
|
+
* case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
|
|
220
|
+
* failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
|
|
221
|
+
* logs and falls back to leaving the caller with only the raw XML strings.
|
|
222
|
+
*/
|
|
223
|
+
private parseHistoricalStepsForDiff;
|
|
204
224
|
private normalizeTestPhaseValue;
|
|
205
225
|
private isTestCaseType;
|
|
206
226
|
private toHistoricalRevision;
|
|
@@ -214,6 +234,10 @@ export default class TicketsDataProvider {
|
|
|
214
234
|
private normalizeHistoricalQueryRoot;
|
|
215
235
|
private historicalErrorMessage;
|
|
216
236
|
private isHistoricalMissingWorkItemError;
|
|
237
|
+
private isHistoricalUnknownFieldError;
|
|
238
|
+
private extractHistoricalUnknownFieldName;
|
|
239
|
+
private fetchHistoricalFieldsBatchAttempt;
|
|
240
|
+
private fetchHistoricalFieldsBatch;
|
|
217
241
|
private fetchHistoricalWorkItemsBatch;
|
|
218
242
|
private executeHistoricalQueryAtAsOf;
|
|
219
243
|
private toHistoricalWorkItemSnapshot;
|
|
@@ -8,11 +8,12 @@ const tfs_data_3 = require("../models/tfs-data");
|
|
|
8
8
|
const tfs_data_4 = require("../models/tfs-data");
|
|
9
9
|
const tfs_data_5 = require("../models/tfs-data");
|
|
10
10
|
const tfs_data_6 = require("../models/tfs-data");
|
|
11
|
+
const testStepParserHelper_1 = require("../utils/testStepParserHelper");
|
|
11
12
|
const logger_1 = require("../utils/logger");
|
|
12
13
|
const pLimit = require('p-limit');
|
|
13
14
|
const HISTORICAL_WIT_API_VERSIONS = ['7.1', '5.1', null];
|
|
14
15
|
const HISTORICAL_BATCH_MAX_IDS = 200;
|
|
15
|
-
const
|
|
16
|
+
const HISTORICAL_REQUIRED_WORK_ITEM_FIELDS = [
|
|
16
17
|
'System.Id',
|
|
17
18
|
'System.WorkItemType',
|
|
18
19
|
'System.Title',
|
|
@@ -23,8 +24,19 @@ const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
|
23
24
|
'System.ChangedDate',
|
|
24
25
|
'System.Description',
|
|
25
26
|
'Microsoft.VSTS.TCM.Steps',
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
];
|
|
28
|
+
// Process-template-specific: not every project defines either of these.
|
|
29
|
+
// workitemsbatch's `fields` filter validates every requested reference name
|
|
30
|
+
// against the project's schema and throws TF51535 ("Cannot find field ...")
|
|
31
|
+
// if one is missing — unlike an unfiltered per-item GET, which just omits an
|
|
32
|
+
// unknown field from the response. toHistoricalWorkItemSnapshot already
|
|
33
|
+
// reads TestPhase via a 4-way fallback expecting it might be absent, so it's
|
|
34
|
+
// safe to drop these on a TF51535 and retry without them (see
|
|
35
|
+
// fetchHistoricalWorkItemsBatch).
|
|
36
|
+
const HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS = ['Elisra.TestPhase', 'Custom.TestPhase'];
|
|
37
|
+
const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
38
|
+
...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
|
|
39
|
+
...HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS,
|
|
28
40
|
];
|
|
29
41
|
/** Default fields fetched per work item in tree/flat query parsing. */
|
|
30
42
|
const WI_DEFAULT_FIELDS = 'System.Description,System.Title,System.WorkItemType,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
|
|
@@ -34,6 +46,9 @@ class TicketsDataProvider {
|
|
|
34
46
|
this.token = '';
|
|
35
47
|
this.queriesList = new Array();
|
|
36
48
|
this.limit = pLimit(10);
|
|
49
|
+
// Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
|
|
50
|
+
// as unknown for this project, so only the first chunk pays the retry cost.
|
|
51
|
+
this.historicalFieldsOverride = null;
|
|
37
52
|
this.orgUrl = orgUrl;
|
|
38
53
|
this.token = token;
|
|
39
54
|
}
|
|
@@ -1395,6 +1410,52 @@ class TicketsDataProvider {
|
|
|
1395
1410
|
}
|
|
1396
1411
|
return String(value).trim();
|
|
1397
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Stores rich-text fields (Description, Steps) with only whitespace trimmed, preserving markup
|
|
1415
|
+
* so the report renderer can clean/render it faithfully. Equality between two snapshots must go
|
|
1416
|
+
* through canonicalizeHistoricalRichTextForCompare instead of comparing this value directly.
|
|
1417
|
+
*/
|
|
1418
|
+
rawHistoricalRichText(value) {
|
|
1419
|
+
if (value == null) {
|
|
1420
|
+
return '';
|
|
1421
|
+
}
|
|
1422
|
+
if (typeof value === 'string') {
|
|
1423
|
+
return value.trim();
|
|
1424
|
+
}
|
|
1425
|
+
return this.normalizeHistoricalCompareValue(value);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Canonicalizes a rich-text field for equality comparison only. Intentionally identical to the
|
|
1429
|
+
* whitespace collapse previously applied at storage time, so Changed/No-changes verdicts and
|
|
1430
|
+
* summary counts are unaffected by storing the raw value for rendering.
|
|
1431
|
+
*/
|
|
1432
|
+
canonicalizeHistoricalRichTextForCompare(value) {
|
|
1433
|
+
return this.normalizeHistoricalCompareValue(value);
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* Parses baseline/compareTo Steps XML into per-step Action/Expected arrays for a changed test
|
|
1437
|
+
* case, so the report can render readable steps instead of raw Steps XML. Best-effort: a parse
|
|
1438
|
+
* failure (malformed XML, shared-step lookup error) must not fail the whole comparison, so it
|
|
1439
|
+
* logs and falls back to leaving the caller with only the raw XML strings.
|
|
1440
|
+
*/
|
|
1441
|
+
async parseHistoricalStepsForDiff(testStepParserHelper, baselineStepsXml, compareToStepsXml) {
|
|
1442
|
+
try {
|
|
1443
|
+
const emptyRevisionMap = new Map();
|
|
1444
|
+
const [baselineSteps, compareToSteps] = await Promise.all([
|
|
1445
|
+
baselineStepsXml
|
|
1446
|
+
? testStepParserHelper.parseTestSteps(baselineStepsXml, emptyRevisionMap)
|
|
1447
|
+
: Promise.resolve([]),
|
|
1448
|
+
compareToStepsXml
|
|
1449
|
+
? testStepParserHelper.parseTestSteps(compareToStepsXml, emptyRevisionMap)
|
|
1450
|
+
: Promise.resolve([]),
|
|
1451
|
+
]);
|
|
1452
|
+
return { baselineSteps, compareToSteps };
|
|
1453
|
+
}
|
|
1454
|
+
catch (err) {
|
|
1455
|
+
logger_1.default.warn(`Failed to parse historical Steps XML for diff rendering: ${(err === null || err === void 0 ? void 0 : err.message) || err}`);
|
|
1456
|
+
return null;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1398
1459
|
normalizeTestPhaseValue(value) {
|
|
1399
1460
|
const rendered = this.normalizeHistoricalCompareValue(value);
|
|
1400
1461
|
if (!rendered) {
|
|
@@ -1571,6 +1632,61 @@ class TicketsDataProvider {
|
|
|
1571
1632
|
}
|
|
1572
1633
|
return status === 404 || status === 410;
|
|
1573
1634
|
}
|
|
1635
|
+
isHistoricalUnknownFieldError(error) {
|
|
1636
|
+
const message = this.historicalErrorMessage(error);
|
|
1637
|
+
return /TF51535|Cannot find field/i.test(message);
|
|
1638
|
+
}
|
|
1639
|
+
// ADO's TF51535 names the specific field it couldn't find, e.g.
|
|
1640
|
+
// "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
|
|
1641
|
+
// define this optional field under different reference names (Elisra.* vs
|
|
1642
|
+
// Custom.*), so drop only the one ADO actually rejected rather than every
|
|
1643
|
+
// optional field, or a network that defines Custom.TestPhase but not
|
|
1644
|
+
// Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
|
|
1645
|
+
extractHistoricalUnknownFieldName(error) {
|
|
1646
|
+
const message = this.historicalErrorMessage(error);
|
|
1647
|
+
const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
|
|
1648
|
+
if (quoted === null || quoted === void 0 ? void 0 : quoted[1])
|
|
1649
|
+
return quoted[1];
|
|
1650
|
+
return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
|
|
1651
|
+
}
|
|
1652
|
+
fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
|
|
1653
|
+
return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
1654
|
+
ids: idChunk,
|
|
1655
|
+
asOf,
|
|
1656
|
+
fields: fieldsForRequest,
|
|
1657
|
+
errorPolicy: 'Omit',
|
|
1658
|
+
})).catch((error) => {
|
|
1659
|
+
if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
|
|
1660
|
+
throw error;
|
|
1661
|
+
}
|
|
1662
|
+
const unknownField = this.extractHistoricalUnknownFieldName(error);
|
|
1663
|
+
// Only strip fields this call actually recognizes as droppable
|
|
1664
|
+
// (named explicitly, or one of the known optional fields as a
|
|
1665
|
+
// last-resort fallback if the message doesn't parse) — never strip a
|
|
1666
|
+
// required field, so an unrelated schema issue still surfaces as an
|
|
1667
|
+
// error instead of silently losing data.
|
|
1668
|
+
const reducedFields = unknownField
|
|
1669
|
+
? fieldsForRequest.filter((field) => field !== unknownField)
|
|
1670
|
+
: fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
|
|
1671
|
+
if (reducedFields.length === fieldsForRequest.length) {
|
|
1672
|
+
throw error;
|
|
1673
|
+
}
|
|
1674
|
+
// Concurrent chunks can each discover a different bad field before any
|
|
1675
|
+
// of them updates the shared override — intersect rather than
|
|
1676
|
+
// overwrite, so one chunk's retry never re-requests a field another
|
|
1677
|
+
// chunk already proved invalid for this project.
|
|
1678
|
+
this.historicalFieldsOverride = this.historicalFieldsOverride
|
|
1679
|
+
? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
|
|
1680
|
+
: reducedFields;
|
|
1681
|
+
logger_1.default.warn(`[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
|
|
1682
|
+
`for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`);
|
|
1683
|
+
return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, reducedFields, attemptsRemaining - 1);
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf) {
|
|
1687
|
+
const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
|
|
1688
|
+
return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length);
|
|
1689
|
+
}
|
|
1574
1690
|
async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
|
|
1575
1691
|
const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
|
|
1576
1692
|
const idChunks = this.chunkHistoricalWorkItemIds(ids);
|
|
@@ -1588,12 +1704,7 @@ class TicketsDataProvider {
|
|
|
1588
1704
|
// two units against the shared concurrency cap, not one — otherwise
|
|
1589
1705
|
// the real ceiling on simultaneous ADO calls would silently double.
|
|
1590
1706
|
Promise.all([
|
|
1591
|
-
this.
|
|
1592
|
-
ids: idChunk,
|
|
1593
|
-
asOf,
|
|
1594
|
-
fields: HISTORICAL_WORK_ITEM_FIELDS,
|
|
1595
|
-
errorPolicy: 'Omit',
|
|
1596
|
-
})),
|
|
1707
|
+
this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
|
|
1597
1708
|
this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
1598
1709
|
ids: idChunk,
|
|
1599
1710
|
asOf,
|
|
@@ -1716,8 +1827,8 @@ class TicketsDataProvider {
|
|
|
1716
1827
|
iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
|
|
1717
1828
|
versionId: this.toHistoricalRevision((_d = workItem === null || workItem === void 0 ? void 0 : workItem.rev) !== null && _d !== void 0 ? _d : fields['System.Rev']),
|
|
1718
1829
|
versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
|
|
1719
|
-
description: this.
|
|
1720
|
-
steps: this.
|
|
1830
|
+
description: this.rawHistoricalRichText(fields['System.Description']),
|
|
1831
|
+
steps: this.rawHistoricalRichText(fields['Microsoft.VSTS.TCM.Steps']),
|
|
1721
1832
|
testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
|
|
1722
1833
|
relatedLinkCount,
|
|
1723
1834
|
workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
|
|
@@ -1853,7 +1964,8 @@ class TicketsDataProvider {
|
|
|
1853
1964
|
...Array.from(compareTo.snapshotMap.keys()),
|
|
1854
1965
|
]);
|
|
1855
1966
|
const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
|
|
1856
|
-
const
|
|
1967
|
+
const testStepParserHelper = new testStepParserHelper_1.default(this.orgUrl, this.token);
|
|
1968
|
+
const rows = await Promise.all(sortedIds.map(async (id) => {
|
|
1857
1969
|
const baselineRow = baseline.snapshotMap.get(id) || null;
|
|
1858
1970
|
const compareToRow = compareTo.snapshotMap.get(id) || null;
|
|
1859
1971
|
const workItemType = (compareToRow === null || compareToRow === void 0 ? void 0 : compareToRow.workItemType) || (baselineRow === null || baselineRow === void 0 ? void 0 : baselineRow.workItemType) || '';
|
|
@@ -1887,7 +1999,12 @@ class TicketsDataProvider {
|
|
|
1887
1999
|
const safeBaseline = baselineRow;
|
|
1888
2000
|
const safeCompareTo = compareToRow;
|
|
1889
2001
|
const changedFields = [];
|
|
1890
|
-
|
|
2002
|
+
const descriptionChanged = this.canonicalizeHistoricalRichTextForCompare(safeBaseline.description) !==
|
|
2003
|
+
this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.description);
|
|
2004
|
+
const stepsChanged = isTestCase &&
|
|
2005
|
+
this.canonicalizeHistoricalRichTextForCompare(safeBaseline.steps) !==
|
|
2006
|
+
this.canonicalizeHistoricalRichTextForCompare(safeCompareTo.steps);
|
|
2007
|
+
if (descriptionChanged) {
|
|
1891
2008
|
changedFields.push('Description');
|
|
1892
2009
|
}
|
|
1893
2010
|
if (safeBaseline.title !== safeCompareTo.title) {
|
|
@@ -1896,7 +2013,7 @@ class TicketsDataProvider {
|
|
|
1896
2013
|
if (safeBaseline.state !== safeCompareTo.state) {
|
|
1897
2014
|
changedFields.push('State');
|
|
1898
2015
|
}
|
|
1899
|
-
if (
|
|
2016
|
+
if (stepsChanged) {
|
|
1900
2017
|
changedFields.push('Steps');
|
|
1901
2018
|
}
|
|
1902
2019
|
if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
|
|
@@ -1905,7 +2022,7 @@ class TicketsDataProvider {
|
|
|
1905
2022
|
if (isTestCase && safeBaseline.relatedLinkCount !== safeCompareTo.relatedLinkCount) {
|
|
1906
2023
|
changedFields.push('Related Link Count');
|
|
1907
2024
|
}
|
|
1908
|
-
const differences = changedFields.map((field) => {
|
|
2025
|
+
const differences = await Promise.all(changedFields.map(async (field) => {
|
|
1909
2026
|
switch (field) {
|
|
1910
2027
|
case 'Description':
|
|
1911
2028
|
return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
|
|
@@ -1913,8 +2030,10 @@ class TicketsDataProvider {
|
|
|
1913
2030
|
return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
|
|
1914
2031
|
case 'State':
|
|
1915
2032
|
return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
|
|
1916
|
-
case 'Steps':
|
|
1917
|
-
|
|
2033
|
+
case 'Steps': {
|
|
2034
|
+
const parsed = await this.parseHistoricalStepsForDiff(testStepParserHelper, safeBaseline.steps, safeCompareTo.steps);
|
|
2035
|
+
return Object.assign({ field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps }, (parsed || {}));
|
|
2036
|
+
}
|
|
1918
2037
|
case 'Test Phase':
|
|
1919
2038
|
return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
|
|
1920
2039
|
case 'Related Link Count':
|
|
@@ -1926,7 +2045,7 @@ class TicketsDataProvider {
|
|
|
1926
2045
|
default:
|
|
1927
2046
|
return { field, baseline: '', compareTo: '' };
|
|
1928
2047
|
}
|
|
1929
|
-
});
|
|
2048
|
+
}));
|
|
1930
2049
|
return {
|
|
1931
2050
|
id,
|
|
1932
2051
|
workItemType,
|
|
@@ -1938,7 +2057,7 @@ class TicketsDataProvider {
|
|
|
1938
2057
|
differences,
|
|
1939
2058
|
workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
|
|
1940
2059
|
};
|
|
1941
|
-
});
|
|
2060
|
+
}));
|
|
1942
2061
|
const summary = rows.reduce((acc, row) => {
|
|
1943
2062
|
if (row.compareStatus === 'Added')
|
|
1944
2063
|
acc.addedCount += 1;
|