@elisra-devops/docgen-data-provider 1.139.0 → 1.141.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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +2 -0
  2. package/bin/helpers/tfs.js +21 -14
  3. package/bin/helpers/tfs.js.map +1 -1
  4. package/bin/modules/JfrogDataProvider.js +1 -1
  5. package/bin/modules/JfrogDataProvider.js.map +1 -1
  6. package/bin/modules/PipelinesDataProvider.js +1 -1
  7. package/bin/modules/PipelinesDataProvider.js.map +1 -1
  8. package/bin/modules/ResultDataProvider.js +21 -15
  9. package/bin/modules/ResultDataProvider.js.map +1 -1
  10. package/bin/modules/TicketsDataProvider.d.ts +3 -3
  11. package/bin/modules/TicketsDataProvider.js +80 -54
  12. package/bin/modules/TicketsDataProvider.js.map +1 -1
  13. package/bin/tests/modules/JfrogDataProvider.test.js +3 -1
  14. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -1
  15. package/bin/tests/modules/ResultDataProvider.test.js +6 -2
  16. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -1
  17. package/bin/tests/modules/ticketsDataProvider.historical.test.js +111 -42
  18. package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -1
  19. package/bin/tests/utils/logger.test.d.ts +1 -0
  20. package/bin/tests/utils/logger.test.js +238 -0
  21. package/bin/tests/utils/logger.test.js.map +1 -0
  22. package/bin/tests/utils/runContext.test.d.ts +1 -0
  23. package/bin/tests/utils/runContext.test.js +51 -0
  24. package/bin/tests/utils/runContext.test.js.map +1 -0
  25. package/bin/utils/logSink.d.ts +25 -0
  26. package/bin/utils/logSink.js +16 -0
  27. package/bin/utils/logSink.js.map +1 -0
  28. package/bin/utils/logger.d.ts +6 -0
  29. package/bin/utils/logger.js +219 -27
  30. package/bin/utils/logger.js.map +1 -1
  31. package/bin/utils/runContext.d.ts +8 -0
  32. package/bin/utils/runContext.js +12 -0
  33. package/bin/utils/runContext.js.map +1 -0
  34. package/eslint.config.mjs +40 -0
  35. package/package.json +8 -3
  36. package/src/helpers/tfs.ts +19 -15
  37. package/src/modules/JfrogDataProvider.ts +1 -1
  38. package/src/modules/PipelinesDataProvider.ts +1 -1
  39. package/src/modules/ResultDataProvider.ts +20 -15
  40. package/src/modules/TicketsDataProvider.ts +93 -74
  41. package/src/tests/modules/JfrogDataProvider.test.ts +3 -1
  42. package/src/tests/modules/ResultDataProvider.test.ts +6 -2
  43. package/src/tests/modules/ticketsDataProvider.historical.test.ts +128 -43
  44. package/src/tests/utils/logger.test.ts +270 -0
  45. package/src/tests/utils/runContext.test.ts +48 -0
  46. package/src/utils/logSink.ts +44 -0
  47. package/src/utils/logger.ts +229 -29
  48. package/src/utils/runContext.ts +25 -0
@@ -10,7 +10,7 @@ export default class TicketsDataProvider {
10
10
  token: string;
11
11
  queriesList: Array<any>;
12
12
  private limit;
13
- private historicalFieldsOverride;
13
+ private historicalTestPhaseFieldByProject;
14
14
  constructor(orgUrl: string, token: string);
15
15
  /**
16
16
  * Extracts the Azure DevOps project name from a WIQL URL.
@@ -235,8 +235,8 @@ export default class TicketsDataProvider {
235
235
  private historicalErrorMessage;
236
236
  private isHistoricalMissingWorkItemError;
237
237
  private isHistoricalUnknownFieldError;
238
- private extractHistoricalUnknownFieldName;
239
- private fetchHistoricalFieldsBatchAttempt;
238
+ private resolveHistoricalTestPhaseField;
239
+ private discoverHistoricalTestPhaseField;
240
240
  private fetchHistoricalFieldsBatch;
241
241
  private fetchHistoricalWorkItemsBatch;
242
242
  private executeHistoricalQueryAtAsOf;
@@ -46,9 +46,11 @@ class TicketsDataProvider {
46
46
  this.token = '';
47
47
  this.queriesList = new Array();
48
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;
49
+ // Resolved once per project via resolveHistoricalTestPhaseField, so a
50
+ // compare's baseline and compareTo snapshots (and repeated queries against
51
+ // the same provider instance) only pay the discovery call once. Value is
52
+ // the real reference name, or null if the project defines no such field.
53
+ this.historicalTestPhaseFieldByProject = new Map();
52
54
  this.orgUrl = orgUrl;
53
55
  this.token = token;
54
56
  }
@@ -611,8 +613,7 @@ class TicketsDataProvider {
611
613
  : null;
612
614
  }
613
615
  catch (err) {
614
- logger_1.default.error(`Error occurred while constructing the customer query list ${err.message} with query ${JSON.stringify(rootQuery)}`);
615
- logger_1.default.error(`Error stack ${err.message}`);
616
+ logger_1.default.error(`Error occurred while constructing the customer query list with query ${JSON.stringify(rootQuery)}`, err);
616
617
  return null;
617
618
  }
618
619
  }
@@ -1636,59 +1637,80 @@ class TicketsDataProvider {
1636
1637
  const message = this.historicalErrorMessage(error);
1637
1638
  return /TF51535|Cannot find field/i.test(message);
1638
1639
  }
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;
1640
+ // Different networks define the "Test Phase" custom field under different
1641
+ // reference names (Elisra.TestPhase vs Custom.TestPhase, or something else
1642
+ // entirely on a third network) — hardcoding candidate names and guessing is
1643
+ // inherently fragile. Resolve the project's real field list once (Fields -
1644
+ // List: GET {org}/{project}/_apis/wit/fields, supported since old TFS API
1645
+ // versions) and use whichever reference name actually matches, by
1646
+ // reference name or by friendly name. Resolves to null if the project
1647
+ // defines no such field at all.
1648
+ //
1649
+ // Caches the in-flight promise, not just the resolved value: a compare's
1650
+ // baseline and compareTo snapshots run concurrently against the same
1651
+ // provider instance, and both would otherwise read the cache before either
1652
+ // had a value to store, issuing the discovery request twice.
1653
+ resolveHistoricalTestPhaseField(project, apiVersion) {
1654
+ const cached = this.historicalTestPhaseFieldByProject.get(project);
1655
+ if (cached)
1656
+ return cached;
1657
+ const promise = this.discoverHistoricalTestPhaseField(project, apiVersion);
1658
+ this.historicalTestPhaseFieldByProject.set(project, promise);
1659
+ return promise;
1660
+ }
1661
+ async discoverHistoricalTestPhaseField(project, apiVersion) {
1662
+ const fieldsUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/fields`, apiVersion);
1663
+ try {
1664
+ const response = await tfs_1.TFSServices.getItemContent(fieldsUrl, this.token);
1665
+ const fields = Array.isArray(response === null || response === void 0 ? void 0 : response.value) ? response.value : [];
1666
+ const match = fields.find((field) => HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((candidate) => String((field === null || field === void 0 ? void 0 : field.referenceName) || '').toLowerCase() === candidate.toLowerCase())) || fields.find((field) => /test\s*phase/i.test(String((field === null || field === void 0 ? void 0 : field.name) || '')));
1667
+ const resolved = (match === null || match === void 0 ? void 0 : match.referenceName) ? String(match.referenceName) : null;
1668
+ if (!resolved) {
1669
+ logger_1.default.warn(`[historical-workitems] no Test Phase field defined for project '${project}'`);
1670
+ }
1671
+ return resolved;
1672
+ }
1673
+ catch (error) {
1674
+ logger_1.default.warn(`[historical-workitems] could not list fields for project '${project}' to resolve the ` +
1675
+ `Test Phase field name, omitting it: ${this.historicalErrorMessage(error)}`);
1676
+ return null;
1677
+ }
1651
1678
  }
1652
- fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
1679
+ fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest) {
1653
1680
  return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1654
1681
  ids: idChunk,
1655
1682
  asOf,
1656
1683
  fields: fieldsForRequest,
1657
1684
  errorPolicy: 'Omit',
1658
1685
  })).catch((error) => {
1659
- if (attemptsRemaining <= 0 || !this.isHistoricalUnknownFieldError(error)) {
1686
+ // Defensive net: the resolved field name came from the project's own
1687
+ // field list, so this shouldn't fire, but if it somehow still does,
1688
+ // fall back to the required fields and poison the cache so later
1689
+ // chunks/queries for this project stop requesting it too.
1690
+ const hasOptionalField = HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((f) => fieldsForRequest.includes(f))
1691
+ || fieldsForRequest.some((f) => !HISTORICAL_REQUIRED_WORK_ITEM_FIELDS.includes(f));
1692
+ if (!hasOptionalField || !this.isHistoricalUnknownFieldError(error)) {
1660
1693
  throw error;
1661
1694
  }
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);
1695
+ logger_1.default.warn(`[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
1696
+ `project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`);
1697
+ this.historicalTestPhaseFieldByProject.set(project, Promise.resolve(null));
1698
+ return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1699
+ ids: idChunk,
1700
+ asOf,
1701
+ fields: HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
1702
+ errorPolicy: 'Omit',
1703
+ }));
1684
1704
  });
1685
1705
  }
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
- }
1690
1706
  async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
1691
1707
  const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
1708
+ // Resolved once per project, before any chunk starts, so every chunk
1709
+ // shares the same field list — no per-request guessing or retry race.
1710
+ const testPhaseField = await this.resolveHistoricalTestPhaseField(project, apiVersion);
1711
+ const fieldsForRequest = testPhaseField
1712
+ ? [...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS, testPhaseField]
1713
+ : HISTORICAL_REQUIRED_WORK_ITEM_FIELDS;
1692
1714
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
1693
1715
  try {
1694
1716
  const chunkResults = await Promise.all(idChunks.map((idChunk) =>
@@ -1704,7 +1726,7 @@ class TicketsDataProvider {
1704
1726
  // two units against the shared concurrency cap, not one — otherwise
1705
1727
  // the real ceiling on simultaneous ADO calls would silently double.
1706
1728
  Promise.all([
1707
- this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
1729
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest),
1708
1730
  this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1709
1731
  ids: idChunk,
1710
1732
  asOf,
@@ -2112,8 +2134,10 @@ class TicketsDataProvider {
2112
2134
  res.push(...subRes.value);
2113
2135
  }
2114
2136
  catch (error) {
2115
- logger_1.default.error(`error populating workitems array`);
2116
- logger_1.default.error(JSON.stringify(error));
2137
+ // JSON.stringify(error) on an AxiosError calls its own .toJSON(), which serializes
2138
+ // config.auth.password / config.headers.Authorization — the PAT, in plaintext. Pass
2139
+ // the error as meta instead and let the logger's redact format scrub it.
2140
+ logger_1.default.error(`error populating workitems array`, error);
2117
2141
  return [];
2118
2142
  }
2119
2143
  }
@@ -2124,8 +2148,10 @@ class TicketsDataProvider {
2124
2148
  res.push(...subRes.value);
2125
2149
  }
2126
2150
  catch (error) {
2127
- logger_1.default.error(`error populating workitems array`);
2128
- logger_1.default.error(JSON.stringify(error));
2151
+ // JSON.stringify(error) on an AxiosError calls its own .toJSON(), which serializes
2152
+ // config.auth.password / config.headers.Authorization — the PAT, in plaintext. Pass
2153
+ // the error as meta instead and let the logger's redact format scrub it.
2154
+ logger_1.default.error(`error populating workitems array`, error);
2129
2155
  return [];
2130
2156
  }
2131
2157
  }
@@ -2249,8 +2275,9 @@ class TicketsDataProvider {
2249
2275
  return attachmentList;
2250
2276
  }
2251
2277
  catch (e) {
2252
- logger_1.default.error(`error fetching attachments for work item ${id}`);
2253
- logger_1.default.error(`${JSON.stringify(e)}`);
2278
+ // JSON.stringify(e) on an Error returns "{}" — own enumerable props only, message/stack
2279
+ // are non-enumerable — so this logged literally nothing useful. Pass it as meta instead.
2280
+ logger_1.default.error(`error fetching attachments for work item ${id}`, e);
2254
2281
  return [];
2255
2282
  }
2256
2283
  }
@@ -2470,8 +2497,7 @@ class TicketsDataProvider {
2470
2497
  return { tree1: tree1Node, tree2: tree2Node };
2471
2498
  }
2472
2499
  catch (err) {
2473
- logger_1.default.error(`Error occurred while constructing the query list ${err.message} with query ${JSON.stringify(rootQuery)}`);
2474
- logger_1.default.error(`Error stack ${err.message}`);
2500
+ logger_1.default.error(`Error occurred while constructing the query list with query ${JSON.stringify(rootQuery)}`, err);
2475
2501
  }
2476
2502
  }
2477
2503
  /**