@elisra-devops/docgen-data-provider 1.137.0 → 1.138.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 +5 -0
- package/bin/modules/TicketsDataProvider.js +112 -11
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +164 -3
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +151 -14
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +195 -3
|
@@ -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.
|
|
@@ -214,6 +215,10 @@ export default class TicketsDataProvider {
|
|
|
214
215
|
private normalizeHistoricalQueryRoot;
|
|
215
216
|
private historicalErrorMessage;
|
|
216
217
|
private isHistoricalMissingWorkItemError;
|
|
218
|
+
private isHistoricalUnknownFieldError;
|
|
219
|
+
private extractHistoricalUnknownFieldName;
|
|
220
|
+
private fetchHistoricalFieldsBatchAttempt;
|
|
221
|
+
private fetchHistoricalFieldsBatch;
|
|
217
222
|
private fetchHistoricalWorkItemsBatch;
|
|
218
223
|
private executeHistoricalQueryAtAsOf;
|
|
219
224
|
private toHistoricalWorkItemSnapshot;
|
|
@@ -12,7 +12,7 @@ const logger_1 = require("../utils/logger");
|
|
|
12
12
|
const pLimit = require('p-limit');
|
|
13
13
|
const HISTORICAL_WIT_API_VERSIONS = ['7.1', '5.1', null];
|
|
14
14
|
const HISTORICAL_BATCH_MAX_IDS = 200;
|
|
15
|
-
const
|
|
15
|
+
const HISTORICAL_REQUIRED_WORK_ITEM_FIELDS = [
|
|
16
16
|
'System.Id',
|
|
17
17
|
'System.WorkItemType',
|
|
18
18
|
'System.Title',
|
|
@@ -23,8 +23,19 @@ const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
|
23
23
|
'System.ChangedDate',
|
|
24
24
|
'System.Description',
|
|
25
25
|
'Microsoft.VSTS.TCM.Steps',
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
];
|
|
27
|
+
// Process-template-specific: not every project defines either of these.
|
|
28
|
+
// workitemsbatch's `fields` filter validates every requested reference name
|
|
29
|
+
// against the project's schema and throws TF51535 ("Cannot find field ...")
|
|
30
|
+
// if one is missing — unlike an unfiltered per-item GET, which just omits an
|
|
31
|
+
// unknown field from the response. toHistoricalWorkItemSnapshot already
|
|
32
|
+
// reads TestPhase via a 4-way fallback expecting it might be absent, so it's
|
|
33
|
+
// safe to drop these on a TF51535 and retry without them (see
|
|
34
|
+
// fetchHistoricalWorkItemsBatch).
|
|
35
|
+
const HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS = ['Elisra.TestPhase', 'Custom.TestPhase'];
|
|
36
|
+
const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
37
|
+
...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
|
|
38
|
+
...HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS,
|
|
28
39
|
];
|
|
29
40
|
/** Default fields fetched per work item in tree/flat query parsing. */
|
|
30
41
|
const WI_DEFAULT_FIELDS = 'System.Description,System.Title,System.WorkItemType,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
|
|
@@ -34,6 +45,9 @@ class TicketsDataProvider {
|
|
|
34
45
|
this.token = '';
|
|
35
46
|
this.queriesList = new Array();
|
|
36
47
|
this.limit = pLimit(10);
|
|
48
|
+
// Set once a workitemsbatch call reports one of HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS
|
|
49
|
+
// as unknown for this project, so only the first chunk pays the retry cost.
|
|
50
|
+
this.historicalFieldsOverride = null;
|
|
37
51
|
this.orgUrl = orgUrl;
|
|
38
52
|
this.token = token;
|
|
39
53
|
}
|
|
@@ -1571,19 +1585,106 @@ class TicketsDataProvider {
|
|
|
1571
1585
|
}
|
|
1572
1586
|
return status === 404 || status === 410;
|
|
1573
1587
|
}
|
|
1588
|
+
isHistoricalUnknownFieldError(error) {
|
|
1589
|
+
const message = this.historicalErrorMessage(error);
|
|
1590
|
+
return /TF51535|Cannot find field/i.test(message);
|
|
1591
|
+
}
|
|
1592
|
+
// ADO's TF51535 names the specific field it couldn't find, e.g.
|
|
1593
|
+
// "TF51535: Cannot find field 'Elisra.TestPhase'." — different networks
|
|
1594
|
+
// define this optional field under different reference names (Elisra.* vs
|
|
1595
|
+
// Custom.*), so drop only the one ADO actually rejected rather than every
|
|
1596
|
+
// optional field, or a network that defines Custom.TestPhase but not
|
|
1597
|
+
// Elisra.TestPhase would lose Test Phase comparison entirely for no reason.
|
|
1598
|
+
extractHistoricalUnknownFieldName(error) {
|
|
1599
|
+
const message = this.historicalErrorMessage(error);
|
|
1600
|
+
const quoted = message.match(/Cannot find field\s*'([^']+)'/i);
|
|
1601
|
+
if (quoted === null || quoted === void 0 ? void 0 : quoted[1])
|
|
1602
|
+
return quoted[1];
|
|
1603
|
+
return HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.find((field) => message.includes(field)) || null;
|
|
1604
|
+
}
|
|
1605
|
+
fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
|
|
1606
|
+
return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
1607
|
+
ids: idChunk,
|
|
1608
|
+
asOf,
|
|
1609
|
+
fields: fieldsForRequest,
|
|
1610
|
+
errorPolicy: 'Omit',
|
|
1611
|
+
})).catch((error) => {
|
|
1612
|
+
if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
|
|
1613
|
+
throw error;
|
|
1614
|
+
}
|
|
1615
|
+
const unknownField = this.extractHistoricalUnknownFieldName(error);
|
|
1616
|
+
// Only strip fields this call actually recognizes as droppable
|
|
1617
|
+
// (named explicitly, or one of the known optional fields as a
|
|
1618
|
+
// last-resort fallback if the message doesn't parse) — never strip a
|
|
1619
|
+
// required field, so an unrelated schema issue still surfaces as an
|
|
1620
|
+
// error instead of silently losing data.
|
|
1621
|
+
const reducedFields = unknownField
|
|
1622
|
+
? fieldsForRequest.filter((field) => field !== unknownField)
|
|
1623
|
+
: fieldsForRequest.filter((field) => !HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.includes(field));
|
|
1624
|
+
if (reducedFields.length === fieldsForRequest.length) {
|
|
1625
|
+
throw error;
|
|
1626
|
+
}
|
|
1627
|
+
// Concurrent chunks can each discover a different bad field before any
|
|
1628
|
+
// of them updates the shared override — intersect rather than
|
|
1629
|
+
// overwrite, so one chunk's retry never re-requests a field another
|
|
1630
|
+
// chunk already proved invalid for this project.
|
|
1631
|
+
this.historicalFieldsOverride = this.historicalFieldsOverride
|
|
1632
|
+
? this.historicalFieldsOverride.filter((field) => reducedFields.includes(field))
|
|
1633
|
+
: reducedFields;
|
|
1634
|
+
logger_1.default.warn(`[historical-workitems] field${unknownField ? ` '${unknownField}'` : '(s)'} not defined ` +
|
|
1635
|
+
`for this project; retrying workitemsbatch without it: ${this.historicalErrorMessage(error)}`);
|
|
1636
|
+
return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, reducedFields, attemptsRemaining - 1);
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf) {
|
|
1640
|
+
const fieldsForRequest = this.historicalFieldsOverride || HISTORICAL_WORK_ITEM_FIELDS;
|
|
1641
|
+
return this.fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.length);
|
|
1642
|
+
}
|
|
1574
1643
|
async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
|
|
1575
1644
|
const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
|
|
1576
1645
|
const idChunks = this.chunkHistoricalWorkItemIds(ids);
|
|
1577
1646
|
try {
|
|
1578
|
-
const
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1647
|
+
const chunkResults = await Promise.all(idChunks.map((idChunk) =>
|
|
1648
|
+
// ADO rejects a workitemsbatch request that combines `fields` with
|
|
1649
|
+
// `$expand` in the same call ("the expand parameter can not be used
|
|
1650
|
+
// with the fields parameter"), so fields and relations are fetched
|
|
1651
|
+
// in two separate batched passes and merged by id below.
|
|
1652
|
+
// errorPolicy: 'Omit' keeps one inaccessible/deleted work item from
|
|
1653
|
+
// failing the whole chunk (ADO's default is 'Fail'), matching the
|
|
1654
|
+
// per-item fallback's tolerance for missing items.
|
|
1655
|
+
// Each pass gets its own this.limit(...) slot (rather than wrapping
|
|
1656
|
+
// both in one) so the two concurrent calls per chunk still count as
|
|
1657
|
+
// two units against the shared concurrency cap, not one — otherwise
|
|
1658
|
+
// the real ceiling on simultaneous ADO calls would silently double.
|
|
1659
|
+
Promise.all([
|
|
1660
|
+
this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
|
|
1661
|
+
this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
1662
|
+
ids: idChunk,
|
|
1663
|
+
asOf,
|
|
1664
|
+
$expand: 'Relations',
|
|
1665
|
+
errorPolicy: 'Omit',
|
|
1666
|
+
})),
|
|
1667
|
+
]).then(([fieldsBatch, relationsBatch]) => {
|
|
1668
|
+
const relationsById = new Map();
|
|
1669
|
+
(Array.isArray(relationsBatch === null || relationsBatch === void 0 ? void 0 : relationsBatch.value) ? relationsBatch.value : []).forEach((item) => {
|
|
1670
|
+
const relId = Number(item === null || item === void 0 ? void 0 : item.id);
|
|
1671
|
+
if (Number.isFinite(relId)) {
|
|
1672
|
+
relationsById.set(relId, Array.isArray(item === null || item === void 0 ? void 0 : item.relations) ? item.relations : []);
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
const fieldsValue = Array.isArray(fieldsBatch === null || fieldsBatch === void 0 ? void 0 : fieldsBatch.value) ? fieldsBatch.value : [];
|
|
1676
|
+
const returnedIds = new Set(fieldsValue.map((item) => Number(item === null || item === void 0 ? void 0 : item.id)));
|
|
1677
|
+
const items = fieldsValue.map((item) => (Object.assign(Object.assign({}, item), { relations: relationsById.get(Number(item === null || item === void 0 ? void 0 : item.id)) || [] })));
|
|
1678
|
+
const skippedWorkItemIds = idChunk.filter((id) => !returnedIds.has(id));
|
|
1679
|
+
if (skippedWorkItemIds.length > 0) {
|
|
1680
|
+
logger_1.default.warn(`[historical-workitems] workitemsbatch omitted ${skippedWorkItemIds.length} ` +
|
|
1681
|
+
`inaccessible/missing work item(s) for asOf ${asOf}: ${skippedWorkItemIds.join(', ')}`);
|
|
1682
|
+
}
|
|
1683
|
+
return { items, skippedWorkItemIds };
|
|
1684
|
+
})));
|
|
1584
1685
|
return {
|
|
1585
|
-
items:
|
|
1586
|
-
skippedWorkItemIds:
|
|
1686
|
+
items: chunkResults.flatMap((chunk) => chunk.items),
|
|
1687
|
+
skippedWorkItemIds: chunkResults.flatMap((chunk) => chunk.skippedWorkItemIds),
|
|
1587
1688
|
};
|
|
1588
1689
|
}
|
|
1589
1690
|
catch (error) {
|