@elisra-devops/docgen-data-provider 1.63.12 → 1.67.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 (94) hide show
  1. package/.github/workflows/ci.yml +26 -9
  2. package/.github/workflows/release.yml +9 -10
  3. package/bin/helpers/tfs.d.ts +3 -0
  4. package/bin/helpers/tfs.js +44 -7
  5. package/bin/helpers/tfs.js.map +1 -1
  6. package/bin/modules/GitDataProvider.d.ts +10 -0
  7. package/bin/modules/GitDataProvider.js +10 -0
  8. package/bin/modules/GitDataProvider.js.map +1 -1
  9. package/bin/modules/MangementDataProvider.js +7 -1
  10. package/bin/modules/MangementDataProvider.js.map +1 -1
  11. package/bin/modules/TestDataProvider.js +0 -1
  12. package/bin/modules/TestDataProvider.js.map +1 -1
  13. package/bin/modules/TicketsDataProvider.d.ts +63 -27
  14. package/bin/modules/TicketsDataProvider.js +226 -122
  15. package/bin/modules/TicketsDataProvider.js.map +1 -1
  16. package/bin/tests/helpers/helper.test.js +279 -0
  17. package/bin/tests/helpers/helper.test.js.map +1 -0
  18. package/bin/{helpers/test → tests/helpers}/tfs.test.js +312 -49
  19. package/bin/tests/helpers/tfs.test.js.map +1 -0
  20. package/bin/tests/index.test.js +25 -0
  21. package/bin/tests/index.test.js.map +1 -0
  22. package/bin/tests/models/tfs-data.test.js +160 -0
  23. package/bin/tests/models/tfs-data.test.js.map +1 -0
  24. package/bin/{modules/test → tests/modules}/JfrogDataProvider.test.js +9 -9
  25. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -0
  26. package/bin/tests/modules/ResultDataProvider.test.js +1942 -0
  27. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -0
  28. package/bin/tests/modules/gitDataProvider.test.js +1888 -0
  29. package/bin/tests/modules/gitDataProvider.test.js.map +1 -0
  30. package/bin/{modules/test → tests/modules}/managmentDataProvider.test.js +39 -31
  31. package/bin/tests/modules/managmentDataProvider.test.js.map +1 -0
  32. package/bin/tests/modules/pipelineDataProvider.test.d.ts +1 -0
  33. package/bin/tests/modules/pipelineDataProvider.test.js +783 -0
  34. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -0
  35. package/bin/tests/modules/testDataProvider.test.d.ts +1 -0
  36. package/bin/tests/modules/testDataProvider.test.js +717 -0
  37. package/bin/tests/modules/testDataProvider.test.js.map +1 -0
  38. package/bin/tests/modules/ticketsDataProvider.test.d.ts +1 -0
  39. package/bin/tests/modules/ticketsDataProvider.test.js +1681 -0
  40. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -0
  41. package/bin/tests/utils/DataProviderUtils.test.d.ts +1 -0
  42. package/bin/tests/utils/DataProviderUtils.test.js +61 -0
  43. package/bin/tests/utils/DataProviderUtils.test.js.map +1 -0
  44. package/bin/tests/utils/testStepParserHelper.test.d.ts +1 -0
  45. package/bin/tests/utils/testStepParserHelper.test.js +359 -0
  46. package/bin/tests/utils/testStepParserHelper.test.js.map +1 -0
  47. package/package.json +10 -1
  48. package/src/helpers/tfs.ts +51 -7
  49. package/src/modules/GitDataProvider.ts +10 -0
  50. package/src/modules/MangementDataProvider.ts +6 -1
  51. package/src/modules/TestDataProvider.ts +0 -1
  52. package/src/modules/TicketsDataProvider.ts +311 -151
  53. package/src/tests/helpers/helper.test.ts +337 -0
  54. package/src/tests/helpers/tfs.test.ts +1092 -0
  55. package/src/tests/index.test.ts +28 -0
  56. package/src/tests/models/tfs-data.test.ts +203 -0
  57. package/src/tests/modules/JfrogDataProvider.test.ts +167 -0
  58. package/src/tests/modules/ResultDataProvider.test.ts +2571 -0
  59. package/src/tests/modules/gitDataProvider.test.ts +2628 -0
  60. package/src/{modules/test → tests/modules}/managmentDataProvider.test.ts +63 -32
  61. package/src/tests/modules/pipelineDataProvider.test.ts +1038 -0
  62. package/src/tests/modules/testDataProvider.test.ts +1046 -0
  63. package/src/tests/modules/ticketsDataProvider.test.ts +2204 -0
  64. package/src/tests/utils/DataProviderUtils.test.ts +76 -0
  65. package/src/tests/utils/testStepParserHelper.test.ts +437 -0
  66. package/tsconfig.json +1 -0
  67. package/bin/helpers/test/tfs.test.js.map +0 -1
  68. package/bin/modules/test/JfrogDataProvider.test.js.map +0 -1
  69. package/bin/modules/test/ResultDataProvider.test.js +0 -444
  70. package/bin/modules/test/ResultDataProvider.test.js.map +0 -1
  71. package/bin/modules/test/gitDataProvider.test.js +0 -433
  72. package/bin/modules/test/gitDataProvider.test.js.map +0 -1
  73. package/bin/modules/test/managmentDataProvider.test.js.map +0 -1
  74. package/bin/modules/test/pipelineDataProvider.test.js +0 -237
  75. package/bin/modules/test/pipelineDataProvider.test.js.map +0 -1
  76. package/bin/modules/test/testDataProvider.test.js +0 -234
  77. package/bin/modules/test/testDataProvider.test.js.map +0 -1
  78. package/bin/modules/test/ticketsDataProvider.test.js +0 -322
  79. package/bin/modules/test/ticketsDataProvider.test.js.map +0 -1
  80. package/src/helpers/test/tfs.test.ts +0 -748
  81. package/src/modules/test/JfrogDataProvider.test.ts +0 -171
  82. package/src/modules/test/ResultDataProvider.test.ts +0 -542
  83. package/src/modules/test/gitDataProvider.test.ts +0 -691
  84. package/src/modules/test/pipelineDataProvider.test.ts +0 -292
  85. package/src/modules/test/testDataProvider.test.ts +0 -318
  86. package/src/modules/test/ticketsDataProvider.test.ts +0 -434
  87. /package/bin/{helpers/test/tfs.test.d.ts → tests/helpers/helper.test.d.ts} +0 -0
  88. /package/bin/{modules/test/JfrogDataProvider.test.d.ts → tests/helpers/tfs.test.d.ts} +0 -0
  89. /package/bin/{modules/test/ResultDataProvider.test.d.ts → tests/index.test.d.ts} +0 -0
  90. /package/bin/{modules/test/gitDataProvider.test.d.ts → tests/models/tfs-data.test.d.ts} +0 -0
  91. /package/bin/{modules/test/managmentDataProvider.test.d.ts → tests/modules/JfrogDataProvider.test.d.ts} +0 -0
  92. /package/bin/{modules/test/pipelineDataProvider.test.d.ts → tests/modules/ResultDataProvider.test.d.ts} +0 -0
  93. /package/bin/{modules/test/testDataProvider.test.d.ts → tests/modules/gitDataProvider.test.d.ts} +0 -0
  94. /package/bin/{modules/test/ticketsDataProvider.test.d.ts → tests/modules/managmentDataProvider.test.d.ts} +0 -0
@@ -69,7 +69,9 @@ export default class TicketsDataProvider {
69
69
  const fieldsArr = Array.isArray(fieldsResp?.value) ? fieldsResp.value : [];
70
70
  const candidates = fieldsArr
71
71
  .filter((f: any) => {
72
- const nm = String(f?.name || '').toLowerCase().replace(/_/g, ' ');
72
+ const nm = String(f?.name || '')
73
+ .toLowerCase()
74
+ .replace(/_/g, ' ');
73
75
  return nm.includes('requirement type');
74
76
  })
75
77
  .map((f: any) => f?.referenceName)
@@ -569,7 +571,7 @@ export default class TicketsDataProvider {
569
571
  queries,
570
572
  false,
571
573
  null,
572
- ['Epic', 'Feature', 'Requirement'],
574
+ ['epic', 'feature', 'requirement'],
573
575
  [],
574
576
  undefined,
575
577
  undefined,
@@ -643,8 +645,8 @@ export default class TicketsDataProvider {
643
645
  queries,
644
646
  onlySourceSide,
645
647
  null,
646
- ['Epic', 'Feature', 'Requirement'],
647
- ['Epic', 'Feature', 'Requirement'],
648
+ ['epic', 'feature', 'requirement'],
649
+ ['epic', 'feature', 'requirement'],
648
650
  'sys', // Source area filter for tree1: System area paths
649
651
  'soft' // Target area filter for tree1: Software area paths (tree2 will be reversed automatically)
650
652
  );
@@ -660,8 +662,8 @@ export default class TicketsDataProvider {
660
662
  folder,
661
663
  false,
662
664
  null,
663
- ['Epic', 'Feature', 'Requirement'],
664
- ['Epic', 'Feature', 'Requirement'],
665
+ ['epic', 'feature', 'requirement'],
666
+ ['epic', 'feature', 'requirement'],
665
667
  undefined,
666
668
  undefined,
667
669
  true
@@ -1740,9 +1742,12 @@ export default class TicketsDataProvider {
1740
1742
  targetAreaFilter?: string,
1741
1743
  includeTreeQueries: boolean = false,
1742
1744
  excludedFolderNames: string[] = [],
1743
- includeFlatQueries: boolean = false
1745
+ includeFlatQueries: boolean = false,
1746
+ workItemTypeCache?: Map<string, string | null>
1744
1747
  ): Promise<any> {
1745
1748
  try {
1749
+ // Per-invocation cache for ID->WorkItemType lookups; avoids global state and is safe for concurrency.
1750
+ const typeCache = workItemTypeCache ?? new Map<string, string | null>();
1746
1751
  const shouldSkipFolder =
1747
1752
  rootQuery?.isFolder &&
1748
1753
  excludedFolderNames.some(
@@ -1766,14 +1771,17 @@ export default class TicketsDataProvider {
1766
1771
 
1767
1772
  if (rootQuery.queryType === 'flat' && includeFlatQueries) {
1768
1773
  const allTypes = Array.from(new Set([...(sources || []), ...(targets || [])]));
1769
- const typesOk = this.matchesFlatWorkItemTypeCondition(wiql, allTypes);
1774
+ const typesOk = await this.matchesFlatWorkItemTypeConditionAsync(
1775
+ rootQuery,
1776
+ wiql,
1777
+ allTypes,
1778
+ typeCache
1779
+ );
1770
1780
 
1771
1781
  if (typesOk) {
1772
1782
  const allowTree1 =
1773
1783
  !onlyTestReq &&
1774
- (sourceAreaFilter
1775
- ? this.matchesFlatAreaCondition(wiql, sourceAreaFilter || '')
1776
- : true);
1784
+ (sourceAreaFilter ? this.matchesFlatAreaCondition(wiql, sourceAreaFilter || '') : true);
1777
1785
  const allowTree2 = targetAreaFilter
1778
1786
  ? this.matchesFlatAreaCondition(wiql, targetAreaFilter || '')
1779
1787
  : true;
@@ -1787,7 +1795,25 @@ export default class TicketsDataProvider {
1787
1795
  }
1788
1796
  }
1789
1797
  } else {
1790
- if (!onlyTestReq && this.matchesSourceTargetCondition(wiql, sources, targets)) {
1798
+ let matchesForward = false;
1799
+ if (!onlyTestReq) {
1800
+ matchesForward = await this.matchesSourceTargetConditionAsync(
1801
+ rootQuery,
1802
+ wiql,
1803
+ sources,
1804
+ targets,
1805
+ typeCache
1806
+ );
1807
+ }
1808
+ const matchesReverse = await this.matchesSourceTargetConditionAsync(
1809
+ rootQuery,
1810
+ wiql,
1811
+ targets,
1812
+ sources,
1813
+ typeCache
1814
+ );
1815
+
1816
+ if (matchesForward) {
1791
1817
  const matchesAreaPath =
1792
1818
  sourceAreaFilter || targetAreaFilter
1793
1819
  ? this.matchesAreaPathCondition(wiql, sourceAreaFilter || '', targetAreaFilter || '')
@@ -1797,7 +1823,7 @@ export default class TicketsDataProvider {
1797
1823
  tree1Node = this.buildQueryNode(rootQuery, parentId);
1798
1824
  }
1799
1825
  }
1800
- if (this.matchesSourceTargetCondition(wiql, targets, sources)) {
1826
+ if (matchesReverse) {
1801
1827
  const matchesReverseAreaPath =
1802
1828
  sourceAreaFilter || targetAreaFilter
1803
1829
  ? this.matchesAreaPathCondition(wiql, targetAreaFilter || '', sourceAreaFilter || '')
@@ -1817,20 +1843,22 @@ export default class TicketsDataProvider {
1817
1843
  if (!rootQuery.children) {
1818
1844
  const queryUrl = `${rootQuery.url}?$depth=2&$expand=all`;
1819
1845
  const currentQuery = await TFSServices.getItemContent(queryUrl, this.token);
1820
- return currentQuery
1821
- ? await this.structureFetchedQueries(
1822
- currentQuery,
1823
- onlyTestReq,
1824
- currentQuery.id,
1825
- sources,
1826
- targets,
1827
- sourceAreaFilter,
1828
- targetAreaFilter,
1829
- includeTreeQueries,
1830
- excludedFolderNames,
1831
- includeFlatQueries
1832
- )
1833
- : { tree1: null, tree2: null };
1846
+ if (!currentQuery) {
1847
+ return { tree1: null, tree2: null };
1848
+ }
1849
+ return await this.structureFetchedQueries(
1850
+ currentQuery,
1851
+ onlyTestReq,
1852
+ currentQuery.id,
1853
+ sources,
1854
+ targets,
1855
+ sourceAreaFilter,
1856
+ targetAreaFilter,
1857
+ includeTreeQueries,
1858
+ excludedFolderNames,
1859
+ includeFlatQueries,
1860
+ typeCache
1861
+ );
1834
1862
  }
1835
1863
 
1836
1864
  // Process children recursively
@@ -1846,7 +1874,8 @@ export default class TicketsDataProvider {
1846
1874
  targetAreaFilter,
1847
1875
  includeTreeQueries,
1848
1876
  excludedFolderNames,
1849
- includeFlatQueries
1877
+ includeFlatQueries,
1878
+ typeCache
1850
1879
  )
1851
1880
  )
1852
1881
  );
@@ -1905,7 +1934,8 @@ export default class TicketsDataProvider {
1905
1934
  const tgtFilter = (targetAreaFilter || '').toLowerCase().trim();
1906
1935
 
1907
1936
  const extractAreaPaths = (owner: 'source' | 'target'): string[] => {
1908
- const re = new RegExp(`${owner}\\.\\[system\\.areapath\\][^']*'([^']+)'`, 'gi');
1937
+ const ownerPattern = `(?:${owner}|\\[${owner}\\])`;
1938
+ const re = new RegExp(`${ownerPattern}\\.\\[system\\.areapath\\][^']*'([^']+)'`, 'gi');
1909
1939
  const results: string[] = [];
1910
1940
  let match: RegExpExecArray | null;
1911
1941
  while ((match = re.exec(wiqlLower)) !== null) {
@@ -1930,144 +1960,300 @@ export default class TicketsDataProvider {
1930
1960
  return hasSourceAreaPath && hasTargetAreaPath;
1931
1961
  }
1932
1962
 
1963
+ private async matchesSourceTargetConditionAsync(
1964
+ queryNode: any,
1965
+ wiql: string,
1966
+ source: string[],
1967
+ target: string[],
1968
+ workItemTypeCache: Map<string, string | null>
1969
+ ): Promise<boolean> {
1970
+ /**
1971
+ * Matches source+target constraints for link WIQL.
1972
+ * For each side (Source/Target) we accept either:
1973
+ * - explicit `[System.WorkItemType]` constraints (all types must be within the allowed list), or
1974
+ * - explicit `[System.Id]` constraints when no type constraint exists (type is fetched by id and validated).
1975
+ */
1976
+ const sourceOk = await this.isLinkSideAllowedByTypeOrId(
1977
+ queryNode,
1978
+ wiql,
1979
+ 'Source',
1980
+ source,
1981
+ workItemTypeCache
1982
+ );
1983
+ if (!sourceOk) return false;
1984
+ const targetOk = await this.isLinkSideAllowedByTypeOrId(
1985
+ queryNode,
1986
+ wiql,
1987
+ 'Target',
1988
+ target,
1989
+ workItemTypeCache
1990
+ );
1991
+ return targetOk;
1992
+ }
1993
+
1994
+ private async matchesFlatWorkItemTypeConditionAsync(
1995
+ queryNode: any,
1996
+ wiql: string,
1997
+ allowedTypes: string[],
1998
+ workItemTypeCache: Map<string, string | null>
1999
+ ): Promise<boolean> {
2000
+ return this.isFlatQueryAllowedByTypeOrId(queryNode, wiql, allowedTypes, workItemTypeCache);
2001
+ }
2002
+
2003
+ // Build a normalized node object for tree outputs
2004
+ private buildQueryNode(rootQuery: any, parentId: any) {
2005
+ return {
2006
+ id: rootQuery.id,
2007
+ pId: parentId,
2008
+ value: rootQuery.name,
2009
+ title: rootQuery.name,
2010
+ queryType: rootQuery.queryType,
2011
+ columns: rootQuery.columns,
2012
+ wiql: rootQuery._links.wiql ?? undefined,
2013
+ isValidQuery: true,
2014
+ };
2015
+ }
2016
+
2017
+ private getProjectFromQueryNode(queryNode: any): string | null {
2018
+ const href =
2019
+ queryNode?._links?.wiql?.href ||
2020
+ queryNode?._links?.self?.href ||
2021
+ queryNode?.url ||
2022
+ queryNode?._links?.html?.href;
2023
+ if (!href) return null;
2024
+ return this.getProjectFromWiqlHref(String(href));
2025
+ }
2026
+
1933
2027
  /**
1934
- * Determines whether the given WIQL (Work Item Query Language) string matches the specified
1935
- * source and target conditions. It checks if the WIQL contains references to the specified
1936
- * source and target work item types.
1937
- *
1938
- * Supports both equality (=) and IN operators:
1939
- * - Source.[System.WorkItemType] = 'Epic'
1940
- * - Source.[System.WorkItemType] IN ('Epic', 'Feature', 'Requirement')
1941
- *
1942
- * @param wiql - The WIQL string to evaluate.
1943
- * @param source - An array of source work item types to check for in the WIQL.
1944
- * @param target - An array of target work item types to check for in the WIQL.
1945
- * @returns A boolean indicating whether the WIQL includes at least one valid source work item type
1946
- * and at least one valid target work item type.
2028
+ * Normalizes allowed work item types into a lowercase set.
1947
2029
  */
1948
- private matchesSourceTargetCondition(wiql: string, source: string[], target: string[]): boolean {
1949
- const isSourceIncluded = this.matchesWorkItemTypeCondition(wiql, 'Source', source);
1950
- const isTargetIncluded = this.matchesWorkItemTypeCondition(wiql, 'Target', target);
1951
- return isSourceIncluded && isTargetIncluded;
2030
+ private normalizeAllowedTypes(allowedTypes: string[]): Set<string> {
2031
+ return new Set((allowedTypes || []).map((t) => String(t).trim().toLowerCase()).filter(Boolean));
1952
2032
  }
1953
2033
 
1954
2034
  /**
1955
- * Helper method to check if a WIQL contains valid work item types for a given context (Source/Target).
1956
- * Supports both = and IN operators.
1957
- *
1958
- * @param wiql - The WIQL string to evaluate
1959
- * @param context - Either 'Source' or 'Target'
1960
- * @param allowedTypes - Array of allowed work item types
1961
- * @returns true if all work item types in the WIQL are in the allowedTypes array
2035
+ * Returns true when every value in `found` exists in `allowed`.
1962
2036
  */
1963
- private matchesWorkItemTypeCondition(
1964
- wiql: string,
1965
- context: 'Source' | 'Target',
1966
- allowedTypes: string[]
1967
- ): boolean {
1968
- // If allowedTypes is empty, accept any work item type (for backward compatibility)
1969
- if (allowedTypes.length === 0) {
1970
- return wiql.includes(`${context}.[System.WorkItemType]`);
2037
+ private areAllAllowed(found: Set<string>, allowed: Set<string>): boolean {
2038
+ for (const v of found) {
2039
+ if (!allowed.has(v)) return false;
1971
2040
  }
2041
+ return true;
2042
+ }
1972
2043
 
1973
- const fieldPattern = `${context}.\\[System.WorkItemType\\]`;
1974
-
1975
- // Pattern for equality: Source.[System.WorkItemType] = 'Epic'
1976
- const equalityRegex = new RegExp(`${fieldPattern}\\s*=\\s*'([^']+)'`, 'gi');
2044
+ /**
2045
+ * Builds a regex-ready field selector for WIQL.
2046
+ * - Link queries: Source/Target can appear as `Source.[...]` or `[Source].[...]`
2047
+ * - Flat queries: no owner prefix, e.g. `[System.WorkItemType]`
2048
+ */
2049
+ private buildWiqlFieldPattern(owner: 'Source' | 'Target' | null, fieldRef: string): string {
2050
+ const escapedField = String(fieldRef).replace(/\./g, '\\.');
2051
+ if (!owner) {
2052
+ return `\\[${escapedField}\\]`;
2053
+ }
2054
+ const ownerPattern = `(?:${owner}|\\[${owner}\\])`;
2055
+ return `${ownerPattern}\\.\\[${escapedField}\\]`;
2056
+ }
1977
2057
 
1978
- // Pattern for IN operator: Source.[System.WorkItemType] IN ('Epic', 'Feature', 'Requirement')
1979
- const inRegex = new RegExp(`${fieldPattern}\\s+IN\\s*\\(([^)]+)\\)`, 'gi');
2058
+ /**
2059
+ * Extracts all quoted values used in `=` or `IN (...)` comparisons for a given field.
2060
+ * Returned values are trimmed and lowercased.
2061
+ *
2062
+ * Examples:
2063
+ * - `<field> = 'Epic'`
2064
+ * - `<field> IN ('Epic','Feature')`
2065
+ */
2066
+ private extractQuotedValuesForField(wiql: string, fieldPattern: string): Set<string> {
2067
+ const wiqlStr = String(wiql || '');
2068
+ const found = new Set<string>();
1980
2069
 
1981
- const foundTypes = new Set<string>();
2070
+ const eqRe = new RegExp(`${fieldPattern}\\s*=\\s*'([^']+)'`, 'gi');
2071
+ const inRe = new RegExp(`${fieldPattern}\\s+IN\\s*\\(([^)]+)\\)`, 'gi');
1982
2072
 
1983
- // Extract types from equality operators
1984
- let match;
1985
- while ((match = equalityRegex.exec(wiql)) !== null) {
1986
- foundTypes.add(match[1].trim());
2073
+ let match: RegExpExecArray | null;
2074
+ while ((match = eqRe.exec(wiqlStr)) !== null) {
2075
+ found.add(String(match[1]).trim().toLowerCase());
1987
2076
  }
1988
-
1989
- // Extract types from IN operators
1990
- while ((match = inRegex.exec(wiql)) !== null) {
1991
- const typesString = match[1];
1992
- // Extract all quoted values from the IN clause
1993
- const typeMatches = typesString.matchAll(/'([^']+)'/g);
1994
- for (const typeMatch of typeMatches) {
1995
- foundTypes.add(typeMatch[1].trim());
2077
+ while ((match = inRe.exec(wiqlStr)) !== null) {
2078
+ const inner = String(match[1] || '');
2079
+ for (const mm of inner.matchAll(/'([^']+)'/g)) {
2080
+ found.add(String(mm[1]).trim().toLowerCase());
1996
2081
  }
1997
2082
  }
2083
+ return found;
2084
+ }
1998
2085
 
1999
- // If no work item types found in WIQL, return false
2000
- if (foundTypes.size === 0) {
2001
- return false;
2002
- }
2086
+ /**
2087
+ * Extracts all numeric values used in `=` or `IN (...)` comparisons for a given field.
2088
+ * Supports both quoted and unquoted numbers.
2089
+ *
2090
+ * Examples:
2091
+ * - `<field> = 123`
2092
+ * - `<field> IN (123, '456')`
2093
+ */
2094
+ private extractNumericValuesForField(wiql: string, fieldPattern: string): Set<string> {
2095
+ const wiqlStr = String(wiql || '');
2096
+ const found = new Set<string>();
2003
2097
 
2004
- // Check if all found types are in the allowedTypes array
2005
- for (const type of foundTypes) {
2006
- if (!allowedTypes.includes(type)) {
2007
- // Found a type that's not in the allowed list - reject this query
2008
- return false;
2098
+ const eqRe = new RegExp(`${fieldPattern}\\s*=\\s*'?([0-9]+)'?`, 'gi');
2099
+ const inRe = new RegExp(`${fieldPattern}\\s+IN\\s*\\(([^)]+)\\)`, 'gi');
2100
+
2101
+ let match: RegExpExecArray | null;
2102
+ while ((match = eqRe.exec(wiqlStr)) !== null) {
2103
+ found.add(String(match[1]));
2104
+ }
2105
+ while ((match = inRe.exec(wiqlStr)) !== null) {
2106
+ const inner = String(match[1] || '');
2107
+ for (const mm of inner.matchAll(/'?([0-9]+)'?/g)) {
2108
+ found.add(String(mm[1]));
2009
2109
  }
2010
2110
  }
2011
-
2012
- // All found types are valid
2013
- return true;
2111
+ return found;
2014
2112
  }
2015
2113
 
2016
- // Build a normalized node object for tree outputs
2017
- private buildQueryNode(rootQuery: any, parentId: any) {
2018
- return {
2019
- id: rootQuery.id,
2020
- pId: parentId,
2021
- value: rootQuery.name,
2022
- title: rootQuery.name,
2023
- queryType: rootQuery.queryType,
2024
- columns: rootQuery.columns,
2025
- wiql: rootQuery._links.wiql ?? undefined,
2026
- isValidQuery: true,
2027
- };
2114
+ /**
2115
+ * Extracts work item IDs from WIQL.
2116
+ * - Link queries: pass `context` to match `Source.[System.Id]` / `Target.[System.Id]` (with or without `[Source]`)
2117
+ * - Flat queries: omit `context` to match `[System.Id]`
2118
+ */
2119
+ private extractWorkItemIdsFromWiql(wiql: string, context?: 'Source' | 'Target'): string[] {
2120
+ const fieldPattern = this.buildWiqlFieldPattern(context ?? null, 'System.Id');
2121
+ return [...this.extractNumericValuesForField(String(wiql || ''), fieldPattern).values()];
2028
2122
  }
2029
2123
 
2030
2124
  /**
2031
- * Matches flat query WIQL against allowed work item types.
2032
- * Accept when at least one type is present and all found types are within the allowed set.
2125
+ * Determines if a link-query side (Source/Target) is allowed based on WIQL constraints.
2126
+ *
2127
+ * Rules:
2128
+ * - If `allowedTypes` is empty: preserve legacy behavior by requiring that WorkItemType is present for that side.
2129
+ * - If WIQL contains a WorkItemType constraint for that side: all specified types must be within `allowedTypes`.
2130
+ * - Otherwise, if WIQL contains a System.Id constraint for that side: fetch the work item type(s) by id and validate.
2131
+ * - Otherwise: reject.
2033
2132
  */
2034
- private matchesFlatWorkItemTypeCondition(wiql: string, allowedTypes: string[]): boolean {
2035
- // If allowedTypes is empty, accept any work item type reference
2133
+ private async isLinkSideAllowedByTypeOrId(
2134
+ queryNode: any,
2135
+ wiql: string,
2136
+ context: 'Source' | 'Target',
2137
+ allowedTypes: string[],
2138
+ workItemTypeCache: Map<string, string | null>
2139
+ ): Promise<boolean> {
2140
+ const wiqlStr = String(wiql || '');
2141
+
2142
+ // Preserve existing behavior when no allowedTypes are provided.
2036
2143
  if (!allowedTypes || allowedTypes.length === 0) {
2037
- return /\[System\.WorkItemType\]/i.test(wiql || '');
2144
+ const fieldPresenceRegex = new RegExp(
2145
+ `${this.buildWiqlFieldPattern(context, 'System.WorkItemType')}`,
2146
+ 'i'
2147
+ );
2148
+ return fieldPresenceRegex.test(wiqlStr);
2038
2149
  }
2039
2150
 
2151
+ const allowed = this.normalizeAllowedTypes(allowedTypes);
2152
+ const typeFieldPattern = this.buildWiqlFieldPattern(context, 'System.WorkItemType');
2153
+ const typesInWiql = this.extractQuotedValuesForField(wiqlStr, typeFieldPattern);
2154
+ if (typesInWiql.size > 0) {
2155
+ return this.areAllAllowed(typesInWiql, allowed);
2156
+ }
2157
+
2158
+ const ids = this.extractWorkItemIdsFromWiql(wiqlStr, context);
2159
+ if (ids.length === 0) return false;
2160
+
2161
+ const project = this.getProjectFromQueryNode(queryNode);
2162
+ if (!project) return false;
2163
+
2164
+ for (const id of ids) {
2165
+ const wiType = await this.getWorkItemTypeById(project, id, workItemTypeCache);
2166
+ if (!wiType) return false;
2167
+ if (!allowed.has(String(wiType).toLowerCase())) return false;
2168
+ }
2169
+
2170
+ return true;
2171
+ }
2172
+
2173
+ /**
2174
+ * Determines if a flat query is allowed based on WIQL constraints.
2175
+ *
2176
+ * Rules:
2177
+ * - If `allowedTypes` is empty: preserve legacy behavior by requiring that `[System.WorkItemType]` appears in WIQL.
2178
+ * - If WIQL contains a WorkItemType constraint: all specified types must be within `allowedTypes`.
2179
+ * - Otherwise, if WIQL contains `[System.Id]`: fetch the work item type(s) by id and validate.
2180
+ * - Otherwise: reject.
2181
+ */
2182
+ private async isFlatQueryAllowedByTypeOrId(
2183
+ queryNode: any,
2184
+ wiql: string,
2185
+ allowedTypes: string[],
2186
+ workItemTypeCache: Map<string, string | null>
2187
+ ): Promise<boolean> {
2040
2188
  const wiqlStr = String(wiql || '');
2041
- const eqRe = /\[System\.WorkItemType\]\s*=\s*'([^']+)'/gi;
2042
- const inRe = /\[System\.WorkItemType\]\s+IN\s*\(([^)]+)\)/gi;
2043
2189
 
2044
- const found = new Set<string>();
2045
- let m: RegExpExecArray | null;
2046
- while ((m = eqRe.exec(wiqlStr)) !== null) {
2047
- found.add(m[1].trim().toLowerCase());
2190
+ // Preserve existing behavior when no allowedTypes are provided.
2191
+ if (!allowedTypes || allowedTypes.length === 0) {
2192
+ return /\[System\.WorkItemType\]/i.test(wiqlStr);
2048
2193
  }
2049
- while ((m = inRe.exec(wiqlStr)) !== null) {
2050
- const inner = m[1];
2051
- for (const mm of inner.matchAll(/'([^']+)'/g)) {
2052
- found.add(String(mm[1]).trim().toLowerCase());
2053
- }
2194
+
2195
+ const allowed = this.normalizeAllowedTypes(allowedTypes);
2196
+ const typeFieldPattern = this.buildWiqlFieldPattern(null, 'System.WorkItemType');
2197
+ const typesInWiql = this.extractQuotedValuesForField(wiqlStr, typeFieldPattern);
2198
+ if (typesInWiql.size > 0) {
2199
+ return this.areAllAllowed(typesInWiql, allowed);
2054
2200
  }
2055
2201
 
2056
- if (found.size === 0) return false;
2202
+ const ids = this.extractWorkItemIdsFromWiql(wiqlStr);
2203
+ if (ids.length === 0) return false;
2204
+
2205
+ const project = this.getProjectFromQueryNode(queryNode);
2206
+ if (!project) return false;
2057
2207
 
2058
- const allowed = new Set(allowedTypes.map((t) => String(t).toLowerCase()));
2059
- for (const t of found) {
2060
- if (!allowed.has(t)) return false;
2208
+ for (const id of ids) {
2209
+ const wiType = await this.getWorkItemTypeById(project, id, workItemTypeCache);
2210
+ if (!wiType) return false;
2211
+ if (!allowed.has(String(wiType).toLowerCase())) return false;
2061
2212
  }
2213
+
2062
2214
  return true;
2063
2215
  }
2064
2216
 
2217
+ /**
2218
+ * Fetches the work item type for a given work item ID.
2219
+ * Uses caching to avoid repeated API calls.
2220
+ *
2221
+ * @param project The project name
2222
+ * @param id The work item ID
2223
+ * @param workItemTypeCache The cache map for work item types
2224
+ * @returns The work item type or null if not found
2225
+ */
2226
+ private async getWorkItemTypeById(
2227
+ project: string,
2228
+ id: string,
2229
+ workItemTypeCache: Map<string, string | null>
2230
+ ): Promise<string | null> {
2231
+ const cacheKey = `${project}:${id}`;
2232
+ if (workItemTypeCache.has(cacheKey)) {
2233
+ return workItemTypeCache.get(cacheKey) ?? null;
2234
+ }
2235
+
2236
+ try {
2237
+ const url = `${this.orgUrl}${project}/_apis/wit/workitems/${id}?fields=System.WorkItemType`;
2238
+ const wi = await this.limit(() => TFSServices.getItemContent(url, this.token));
2239
+ const wiType = wi?.fields?.['System.WorkItemType'];
2240
+ const normalized = wiType ? String(wiType) : null;
2241
+ workItemTypeCache.set(cacheKey, normalized);
2242
+ return normalized;
2243
+ } catch (e) {
2244
+ workItemTypeCache.set(cacheKey, null);
2245
+ return null;
2246
+ }
2247
+ }
2248
+
2065
2249
  /**
2066
2250
  * Matches flat query WIQL against an area path filter by checking any referenced [System.AreaPath].
2067
2251
  * Compares only the leaf segment of the path and performs a case-insensitive substring match.
2068
2252
  */
2069
2253
  private matchesFlatAreaCondition(wiql: string, areaFilter: string): boolean {
2070
- const filter = String(areaFilter || '').trim().toLowerCase();
2254
+ const filter = String(areaFilter || '')
2255
+ .trim()
2256
+ .toLowerCase();
2071
2257
  if (!filter) return true;
2072
2258
 
2073
2259
  const wiqlLower = String(wiql || '').toLowerCase();
@@ -2355,30 +2541,4 @@ export default class TicketsDataProvider {
2355
2541
  throw err;
2356
2542
  }
2357
2543
  }
2358
-
2359
- /**
2360
- * Helper method to flatten a tree structure into a flat array of work items
2361
- */
2362
- private flattenTreeToWorkItems(roots: any[]): any[] {
2363
- const result: any[] = [];
2364
-
2365
- const traverse = (node: any) => {
2366
- if (!node) return;
2367
-
2368
- result.push({
2369
- id: node.id,
2370
- title: node.title,
2371
- description: node.description,
2372
- htmlUrl: node.htmlUrl,
2373
- url: node.htmlUrl, // Some nodes might use url instead
2374
- });
2375
-
2376
- if (Array.isArray(node.children)) {
2377
- node.children.forEach(traverse);
2378
- }
2379
- };
2380
-
2381
- roots.forEach(traverse);
2382
- return result;
2383
- }
2384
2544
  }