@elisra-devops/docgen-data-provider 1.63.13 → 1.68.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 (92) hide show
  1. package/.github/workflows/ci.yml +26 -9
  2. package/.github/workflows/release.yml +9 -10
  3. package/README.md +50 -24
  4. package/bin/helpers/tfs.d.ts +3 -0
  5. package/bin/helpers/tfs.js +44 -7
  6. package/bin/helpers/tfs.js.map +1 -1
  7. package/bin/modules/GitDataProvider.d.ts +10 -0
  8. package/bin/modules/GitDataProvider.js +10 -0
  9. package/bin/modules/GitDataProvider.js.map +1 -1
  10. package/bin/modules/TestDataProvider.js +0 -1
  11. package/bin/modules/TestDataProvider.js.map +1 -1
  12. package/bin/modules/TicketsDataProvider.d.ts +63 -24
  13. package/bin/modules/TicketsDataProvider.js +216 -114
  14. package/bin/modules/TicketsDataProvider.js.map +1 -1
  15. package/bin/tests/helpers/helper.test.js +279 -0
  16. package/bin/tests/helpers/helper.test.js.map +1 -0
  17. package/bin/{helpers/test → tests/helpers}/tfs.test.js +312 -49
  18. package/bin/tests/helpers/tfs.test.js.map +1 -0
  19. package/bin/tests/index.test.js +25 -0
  20. package/bin/tests/index.test.js.map +1 -0
  21. package/bin/tests/models/tfs-data.test.js +160 -0
  22. package/bin/tests/models/tfs-data.test.js.map +1 -0
  23. package/bin/{modules/test → tests/modules}/JfrogDataProvider.test.js +9 -9
  24. package/bin/tests/modules/JfrogDataProvider.test.js.map +1 -0
  25. package/bin/tests/modules/ResultDataProvider.test.js +1942 -0
  26. package/bin/tests/modules/ResultDataProvider.test.js.map +1 -0
  27. package/bin/tests/modules/gitDataProvider.test.js +1888 -0
  28. package/bin/tests/modules/gitDataProvider.test.js.map +1 -0
  29. package/bin/{modules/test → tests/modules}/managmentDataProvider.test.js +13 -1
  30. package/bin/tests/modules/managmentDataProvider.test.js.map +1 -0
  31. package/bin/tests/modules/pipelineDataProvider.test.d.ts +1 -0
  32. package/bin/tests/modules/pipelineDataProvider.test.js +783 -0
  33. package/bin/tests/modules/pipelineDataProvider.test.js.map +1 -0
  34. package/bin/tests/modules/testDataProvider.test.d.ts +1 -0
  35. package/bin/tests/modules/testDataProvider.test.js +717 -0
  36. package/bin/tests/modules/testDataProvider.test.js.map +1 -0
  37. package/bin/tests/modules/ticketsDataProvider.test.d.ts +1 -0
  38. package/bin/tests/modules/ticketsDataProvider.test.js +1681 -0
  39. package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -0
  40. package/bin/tests/utils/DataProviderUtils.test.d.ts +1 -0
  41. package/bin/tests/utils/DataProviderUtils.test.js +61 -0
  42. package/bin/tests/utils/DataProviderUtils.test.js.map +1 -0
  43. package/bin/tests/utils/testStepParserHelper.test.d.ts +1 -0
  44. package/bin/tests/utils/testStepParserHelper.test.js +359 -0
  45. package/bin/tests/utils/testStepParserHelper.test.js.map +1 -0
  46. package/package.json +9 -1
  47. package/src/helpers/tfs.ts +51 -7
  48. package/src/modules/GitDataProvider.ts +10 -0
  49. package/src/modules/TestDataProvider.ts +0 -1
  50. package/src/modules/TicketsDataProvider.ts +298 -141
  51. package/src/tests/helpers/helper.test.ts +337 -0
  52. package/src/tests/helpers/tfs.test.ts +1092 -0
  53. package/src/tests/index.test.ts +28 -0
  54. package/src/tests/models/tfs-data.test.ts +203 -0
  55. package/src/tests/modules/JfrogDataProvider.test.ts +167 -0
  56. package/src/tests/modules/ResultDataProvider.test.ts +2571 -0
  57. package/src/tests/modules/gitDataProvider.test.ts +2628 -0
  58. package/src/{modules/test → tests/modules}/managmentDataProvider.test.ts +33 -1
  59. package/src/tests/modules/pipelineDataProvider.test.ts +1038 -0
  60. package/src/tests/modules/testDataProvider.test.ts +1046 -0
  61. package/src/tests/modules/ticketsDataProvider.test.ts +2204 -0
  62. package/src/tests/utils/DataProviderUtils.test.ts +76 -0
  63. package/src/tests/utils/testStepParserHelper.test.ts +437 -0
  64. package/tsconfig.json +1 -0
  65. package/bin/helpers/test/tfs.test.js.map +0 -1
  66. package/bin/modules/test/JfrogDataProvider.test.js.map +0 -1
  67. package/bin/modules/test/ResultDataProvider.test.js +0 -444
  68. package/bin/modules/test/ResultDataProvider.test.js.map +0 -1
  69. package/bin/modules/test/gitDataProvider.test.js +0 -428
  70. package/bin/modules/test/gitDataProvider.test.js.map +0 -1
  71. package/bin/modules/test/managmentDataProvider.test.js.map +0 -1
  72. package/bin/modules/test/pipelineDataProvider.test.js +0 -237
  73. package/bin/modules/test/pipelineDataProvider.test.js.map +0 -1
  74. package/bin/modules/test/testDataProvider.test.js +0 -234
  75. package/bin/modules/test/testDataProvider.test.js.map +0 -1
  76. package/bin/modules/test/ticketsDataProvider.test.js +0 -348
  77. package/bin/modules/test/ticketsDataProvider.test.js.map +0 -1
  78. package/src/helpers/test/tfs.test.ts +0 -748
  79. package/src/modules/test/JfrogDataProvider.test.ts +0 -171
  80. package/src/modules/test/ResultDataProvider.test.ts +0 -542
  81. package/src/modules/test/gitDataProvider.test.ts +0 -645
  82. package/src/modules/test/pipelineDataProvider.test.ts +0 -292
  83. package/src/modules/test/testDataProvider.test.ts +0 -318
  84. package/src/modules/test/ticketsDataProvider.test.ts +0 -462
  85. /package/bin/{helpers/test/tfs.test.d.ts → tests/helpers/helper.test.d.ts} +0 -0
  86. /package/bin/{modules/test/JfrogDataProvider.test.d.ts → tests/helpers/tfs.test.d.ts} +0 -0
  87. /package/bin/{modules/test/ResultDataProvider.test.d.ts → tests/index.test.d.ts} +0 -0
  88. /package/bin/{modules/test/gitDataProvider.test.d.ts → tests/models/tfs-data.test.d.ts} +0 -0
  89. /package/bin/{modules/test/managmentDataProvider.test.d.ts → tests/modules/JfrogDataProvider.test.d.ts} +0 -0
  90. /package/bin/{modules/test/pipelineDataProvider.test.d.ts → tests/modules/ResultDataProvider.test.d.ts} +0 -0
  91. /package/bin/{modules/test/testDataProvider.test.d.ts → tests/modules/gitDataProvider.test.d.ts} +0 -0
  92. /package/bin/{modules/test/ticketsDataProvider.test.d.ts → tests/modules/managmentDataProvider.test.d.ts} +0 -0
@@ -1742,9 +1742,12 @@ export default class TicketsDataProvider {
1742
1742
  targetAreaFilter?: string,
1743
1743
  includeTreeQueries: boolean = false,
1744
1744
  excludedFolderNames: string[] = [],
1745
- includeFlatQueries: boolean = false
1745
+ includeFlatQueries: boolean = false,
1746
+ workItemTypeCache?: Map<string, string | null>
1746
1747
  ): Promise<any> {
1747
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>();
1748
1751
  const shouldSkipFolder =
1749
1752
  rootQuery?.isFolder &&
1750
1753
  excludedFolderNames.some(
@@ -1768,7 +1771,12 @@ export default class TicketsDataProvider {
1768
1771
 
1769
1772
  if (rootQuery.queryType === 'flat' && includeFlatQueries) {
1770
1773
  const allTypes = Array.from(new Set([...(sources || []), ...(targets || [])]));
1771
- const typesOk = this.matchesFlatWorkItemTypeCondition(wiql, allTypes);
1774
+ const typesOk = await this.matchesFlatWorkItemTypeConditionAsync(
1775
+ rootQuery,
1776
+ wiql,
1777
+ allTypes,
1778
+ typeCache
1779
+ );
1772
1780
 
1773
1781
  if (typesOk) {
1774
1782
  const allowTree1 =
@@ -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,139 +1960,292 @@ 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
- * @returns A boolean indicating whether the WIQL includes at least one valid source work item type
1943
- * and at least one valid target work item type.
2028
+ * Normalizes allowed work item types into a lowercase set.
1944
2029
  */
1945
- private matchesSourceTargetCondition(wiql: string, source: string[], target: string[]): boolean {
1946
- const isSourceIncluded = this.matchesWorkItemTypeCondition(wiql, 'Source', source);
1947
- const isTargetIncluded = this.matchesWorkItemTypeCondition(wiql, 'Target', target);
1948
- return isSourceIncluded && isTargetIncluded;
2030
+ private normalizeAllowedTypes(allowedTypes: string[]): Set<string> {
2031
+ return new Set((allowedTypes || []).map((t) => String(t).trim().toLowerCase()).filter(Boolean));
1949
2032
  }
1950
2033
 
1951
2034
  /**
1952
- * Helper method to check if a WIQL contains valid work item types for a given context (Source/Target).
1953
- * Supports both = and IN operators.
1954
- *
1955
- * @param wiql - The WIQL string to evaluate
1956
- * @param context - Either 'Source' or 'Target'
1957
- * @param allowedTypes - Array of allowed work item types
1958
- * @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`.
1959
2036
  */
1960
- private matchesWorkItemTypeCondition(
1961
- wiql: string,
1962
- context: 'Source' | 'Target',
1963
- allowedTypes: string[]
1964
- ): boolean {
1965
- const wiqlStr = String(wiql || '');
1966
-
1967
- // If allowedTypes is empty, accept any work item type (for backward compatibility)
1968
- if (!allowedTypes || allowedTypes.length === 0) {
1969
- const fieldPresenceRegex = new RegExp(`${context}\\.\\[System.WorkItemType\\]`, 'i');
1970
- return fieldPresenceRegex.test(wiqlStr);
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\\]`;
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
+ }
1974
2057
 
1975
- // Pattern for equality: Source.[System.WorkItemType] = 'Epic'
1976
- const equalityRegex = new RegExp(`${fieldPattern}\\s*=\\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>();
1977
2069
 
1978
- // Pattern for IN operator: Source.[System.WorkItemType] IN ('Epic', 'Feature', 'Requirement')
1979
- const inRegex = new RegExp(`${fieldPattern}\\s+IN\\s*\\(([^)]+)\\)`, 'gi');
2070
+ const eqRe = new RegExp(`${fieldPattern}\\s*=\\s*'([^']+)'`, 'gi');
2071
+ const inRe = new RegExp(`${fieldPattern}\\s+IN\\s*\\(([^)]+)\\)`, 'gi');
1980
2072
 
1981
- const foundTypes = new Set<string>();
1982
2073
  let match: RegExpExecArray | null;
1983
-
1984
- // Extract types from equality operators
1985
- while ((match = equalityRegex.exec(wiqlStr)) !== null) {
1986
- foundTypes.add(String(match[1]).trim().toLowerCase());
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(wiqlStr)) !== 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(String(typeMatch[1]).trim().toLowerCase());
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 (case-insensitive)
2005
- const allowedSet = new Set(allowedTypes.map((t) => String(t).toLowerCase()));
2006
- for (const type of foundTypes) {
2007
- if (!allowedSet.has(type)) {
2008
- // Found a type that's not in the allowed list - reject this query
2009
- 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]));
2010
2109
  }
2011
2110
  }
2012
-
2013
- // All found types are valid
2014
- return true;
2111
+ return found;
2015
2112
  }
2016
2113
 
2017
- // Build a normalized node object for tree outputs
2018
- private buildQueryNode(rootQuery: any, parentId: any) {
2019
- return {
2020
- id: rootQuery.id,
2021
- pId: parentId,
2022
- value: rootQuery.name,
2023
- title: rootQuery.name,
2024
- queryType: rootQuery.queryType,
2025
- columns: rootQuery.columns,
2026
- wiql: rootQuery._links.wiql ?? undefined,
2027
- isValidQuery: true,
2028
- };
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()];
2029
2122
  }
2030
2123
 
2031
2124
  /**
2032
- * Matches flat query WIQL against allowed work item types.
2033
- * 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.
2034
2132
  */
2035
- private matchesFlatWorkItemTypeCondition(wiql: string, allowedTypes: string[]): boolean {
2036
- // 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.
2037
2143
  if (!allowedTypes || allowedTypes.length === 0) {
2038
- 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);
2039
2149
  }
2040
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> {
2041
2188
  const wiqlStr = String(wiql || '');
2042
- const eqRe = /\[System\.WorkItemType\]\s*=\s*'([^']+)'/gi;
2043
- const inRe = /\[System\.WorkItemType\]\s+IN\s*\(([^)]+)\)/gi;
2044
2189
 
2045
- const found = new Set<string>();
2046
- let m: RegExpExecArray | null;
2047
- while ((m = eqRe.exec(wiqlStr)) !== null) {
2048
- 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);
2049
2193
  }
2050
- while ((m = inRe.exec(wiqlStr)) !== null) {
2051
- const inner = m[1];
2052
- for (const mm of inner.matchAll(/'([^']+)'/g)) {
2053
- found.add(String(mm[1]).trim().toLowerCase());
2054
- }
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);
2055
2200
  }
2056
2201
 
2057
- if (found.size === 0) return false;
2202
+ const ids = this.extractWorkItemIdsFromWiql(wiqlStr);
2203
+ if (ids.length === 0) return false;
2058
2204
 
2059
- const allowed = new Set(allowedTypes.map((t) => String(t).toLowerCase()));
2060
- for (const t of found) {
2061
- if (!allowed.has(t)) return false;
2205
+ const project = this.getProjectFromQueryNode(queryNode);
2206
+ if (!project) return false;
2207
+
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;
2062
2212
  }
2213
+
2063
2214
  return true;
2064
2215
  }
2065
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
+
2066
2249
  /**
2067
2250
  * Matches flat query WIQL against an area path filter by checking any referenced [System.AreaPath].
2068
2251
  * Compares only the leaf segment of the path and performs a case-insensitive substring match.
@@ -2358,30 +2541,4 @@ export default class TicketsDataProvider {
2358
2541
  throw err;
2359
2542
  }
2360
2543
  }
2361
-
2362
- /**
2363
- * Helper method to flatten a tree structure into a flat array of work items
2364
- */
2365
- private flattenTreeToWorkItems(roots: any[]): any[] {
2366
- const result: any[] = [];
2367
-
2368
- const traverse = (node: any) => {
2369
- if (!node) return;
2370
-
2371
- result.push({
2372
- id: node.id,
2373
- title: node.title,
2374
- description: node.description,
2375
- htmlUrl: node.htmlUrl,
2376
- url: node.htmlUrl, // Some nodes might use url instead
2377
- });
2378
-
2379
- if (Array.isArray(node.children)) {
2380
- node.children.forEach(traverse);
2381
- }
2382
- };
2383
-
2384
- roots.forEach(traverse);
2385
- return result;
2386
- }
2387
2544
  }