@elisra-devops/docgen-data-provider 1.139.0 → 1.140.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.
@@ -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
  }
@@ -1636,59 +1638,80 @@ class TicketsDataProvider {
1636
1638
  const message = this.historicalErrorMessage(error);
1637
1639
  return /TF51535|Cannot find field/i.test(message);
1638
1640
  }
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;
1641
+ // Different networks define the "Test Phase" custom field under different
1642
+ // reference names (Elisra.TestPhase vs Custom.TestPhase, or something else
1643
+ // entirely on a third network) — hardcoding candidate names and guessing is
1644
+ // inherently fragile. Resolve the project's real field list once (Fields -
1645
+ // List: GET {org}/{project}/_apis/wit/fields, supported since old TFS API
1646
+ // versions) and use whichever reference name actually matches, by
1647
+ // reference name or by friendly name. Resolves to null if the project
1648
+ // defines no such field at all.
1649
+ //
1650
+ // Caches the in-flight promise, not just the resolved value: a compare's
1651
+ // baseline and compareTo snapshots run concurrently against the same
1652
+ // provider instance, and both would otherwise read the cache before either
1653
+ // had a value to store, issuing the discovery request twice.
1654
+ resolveHistoricalTestPhaseField(project, apiVersion) {
1655
+ const cached = this.historicalTestPhaseFieldByProject.get(project);
1656
+ if (cached)
1657
+ return cached;
1658
+ const promise = this.discoverHistoricalTestPhaseField(project, apiVersion);
1659
+ this.historicalTestPhaseFieldByProject.set(project, promise);
1660
+ return promise;
1661
+ }
1662
+ async discoverHistoricalTestPhaseField(project, apiVersion) {
1663
+ const fieldsUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/fields`, apiVersion);
1664
+ try {
1665
+ const response = await tfs_1.TFSServices.getItemContent(fieldsUrl, this.token);
1666
+ const fields = Array.isArray(response === null || response === void 0 ? void 0 : response.value) ? response.value : [];
1667
+ 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) || '')));
1668
+ const resolved = (match === null || match === void 0 ? void 0 : match.referenceName) ? String(match.referenceName) : null;
1669
+ if (!resolved) {
1670
+ logger_1.default.warn(`[historical-workitems] no Test Phase field defined for project '${project}'`);
1671
+ }
1672
+ return resolved;
1673
+ }
1674
+ catch (error) {
1675
+ logger_1.default.warn(`[historical-workitems] could not list fields for project '${project}' to resolve the ` +
1676
+ `Test Phase field name, omitting it: ${this.historicalErrorMessage(error)}`);
1677
+ return null;
1678
+ }
1651
1679
  }
1652
- fetchHistoricalFieldsBatchAttempt(workItemsBatchUrl, idChunk, asOf, fieldsForRequest, attemptsRemaining) {
1680
+ fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest) {
1653
1681
  return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1654
1682
  ids: idChunk,
1655
1683
  asOf,
1656
1684
  fields: fieldsForRequest,
1657
1685
  errorPolicy: 'Omit',
1658
1686
  })).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) {
1687
+ // Defensive net: the resolved field name came from the project's own
1688
+ // field list, so this shouldn't fire, but if it somehow still does,
1689
+ // fall back to the required fields and poison the cache so later
1690
+ // chunks/queries for this project stop requesting it too.
1691
+ const hasOptionalField = HISTORICAL_OPTIONAL_WORK_ITEM_FIELDS.some((f) => fieldsForRequest.includes(f))
1692
+ || fieldsForRequest.some((f) => !HISTORICAL_REQUIRED_WORK_ITEM_FIELDS.includes(f));
1693
+ if (!hasOptionalField || !this.isHistoricalUnknownFieldError(error)) {
1672
1694
  throw error;
1673
1695
  }
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);
1696
+ logger_1.default.warn(`[historical-workitems] resolved Test Phase field was rejected by workitemsbatch for ` +
1697
+ `project '${project}'; retrying without it: ${this.historicalErrorMessage(error)}`);
1698
+ this.historicalTestPhaseFieldByProject.set(project, Promise.resolve(null));
1699
+ return this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1700
+ ids: idChunk,
1701
+ asOf,
1702
+ fields: HISTORICAL_REQUIRED_WORK_ITEM_FIELDS,
1703
+ errorPolicy: 'Omit',
1704
+ }));
1684
1705
  });
1685
1706
  }
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
1707
  async fetchHistoricalWorkItemsBatch(project, ids, asOf, apiVersion) {
1691
1708
  const workItemsBatchUrl = this.appendApiVersion(`${this.orgUrl}${project}/_apis/wit/workitemsbatch`, apiVersion);
1709
+ // Resolved once per project, before any chunk starts, so every chunk
1710
+ // shares the same field list — no per-request guessing or retry race.
1711
+ const testPhaseField = await this.resolveHistoricalTestPhaseField(project, apiVersion);
1712
+ const fieldsForRequest = testPhaseField
1713
+ ? [...HISTORICAL_REQUIRED_WORK_ITEM_FIELDS, testPhaseField]
1714
+ : HISTORICAL_REQUIRED_WORK_ITEM_FIELDS;
1692
1715
  const idChunks = this.chunkHistoricalWorkItemIds(ids);
1693
1716
  try {
1694
1717
  const chunkResults = await Promise.all(idChunks.map((idChunk) =>
@@ -1704,7 +1727,7 @@ class TicketsDataProvider {
1704
1727
  // two units against the shared concurrency cap, not one — otherwise
1705
1728
  // the real ceiling on simultaneous ADO calls would silently double.
1706
1729
  Promise.all([
1707
- this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf),
1730
+ this.fetchHistoricalFieldsBatch(workItemsBatchUrl, idChunk, asOf, project, fieldsForRequest),
1708
1731
  this.limit(() => tfs_1.TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
1709
1732
  ids: idChunk,
1710
1733
  asOf,