@elisra-devops/docgen-data-provider 1.106.0 → 1.108.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/.github/workflows/release.yml +28 -8
- package/README.md +7 -0
- package/bin/modules/TicketsDataProvider.d.ts +39 -0
- package/bin/modules/TicketsDataProvider.js +564 -2
- package/bin/modules/TicketsDataProvider.js.map +1 -1
- package/bin/tests/modules/ticketsDataProvider.historical.test.d.ts +1 -0
- package/bin/tests/modules/ticketsDataProvider.historical.test.js +741 -0
- package/bin/tests/modules/ticketsDataProvider.historical.test.js.map +1 -0
- package/bin/tests/modules/ticketsDataProvider.test.js +34 -1
- package/bin/tests/modules/ticketsDataProvider.test.js.map +1 -1
- package/package.json +1 -1
- package/src/modules/TicketsDataProvider.ts +719 -2
- package/src/tests/modules/ticketsDataProvider.historical.test.ts +876 -0
- package/src/tests/modules/ticketsDataProvider.test.ts +50 -1
|
@@ -27,6 +27,55 @@ type DocTypeBranchConfig = {
|
|
|
27
27
|
fallbackStart?: any;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
type HistoricalQueryListItem = {
|
|
31
|
+
id: string;
|
|
32
|
+
queryName: string;
|
|
33
|
+
path: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type HistoricalWorkItemSnapshot = {
|
|
37
|
+
id: number;
|
|
38
|
+
workItemType: string;
|
|
39
|
+
title: string;
|
|
40
|
+
state: string;
|
|
41
|
+
areaPath: string;
|
|
42
|
+
iterationPath: string;
|
|
43
|
+
versionId: number | null;
|
|
44
|
+
versionTimestamp: string;
|
|
45
|
+
description: string;
|
|
46
|
+
steps: string;
|
|
47
|
+
testPhase: string;
|
|
48
|
+
relatedLinkCount: number;
|
|
49
|
+
workItemUrl: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type HistoricalSnapshotResult = {
|
|
53
|
+
queryId: string;
|
|
54
|
+
queryName: string;
|
|
55
|
+
asOf: string;
|
|
56
|
+
total: number;
|
|
57
|
+
rows: HistoricalWorkItemSnapshot[];
|
|
58
|
+
snapshotMap: Map<number, HistoricalWorkItemSnapshot>;
|
|
59
|
+
skippedWorkItemIds: number[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const HISTORICAL_WIT_API_VERSIONS: Array<string | null> = ['7.1', '5.1', null];
|
|
63
|
+
const HISTORICAL_BATCH_MAX_IDS = 200;
|
|
64
|
+
const HISTORICAL_WORK_ITEM_FIELDS = [
|
|
65
|
+
'System.Id',
|
|
66
|
+
'System.WorkItemType',
|
|
67
|
+
'System.Title',
|
|
68
|
+
'System.State',
|
|
69
|
+
'System.AreaPath',
|
|
70
|
+
'System.IterationPath',
|
|
71
|
+
'System.Rev',
|
|
72
|
+
'System.ChangedDate',
|
|
73
|
+
'System.Description',
|
|
74
|
+
'Microsoft.VSTS.TCM.Steps',
|
|
75
|
+
'Elisra.TestPhase',
|
|
76
|
+
'Custom.TestPhase',
|
|
77
|
+
];
|
|
78
|
+
|
|
30
79
|
/** Default fields fetched per work item in tree/flat query parsing. */
|
|
31
80
|
const WI_DEFAULT_FIELDS =
|
|
32
81
|
'System.Description,System.Title,Microsoft.VSTS.TCM.ReproSteps,Microsoft.VSTS.CMMI.Symptom';
|
|
@@ -191,12 +240,21 @@ export default class TicketsDataProvider {
|
|
|
191
240
|
* @param docType document type
|
|
192
241
|
* @returns
|
|
193
242
|
*/
|
|
243
|
+
private normalizeSharedQueriesPath(path: string): string {
|
|
244
|
+
const raw = String(path || '').trim();
|
|
245
|
+
if (!raw) return '';
|
|
246
|
+
const normalized = raw.toLowerCase();
|
|
247
|
+
if (normalized === 'shared' || normalized === 'shared queries') return '';
|
|
248
|
+
return raw;
|
|
249
|
+
}
|
|
250
|
+
|
|
194
251
|
async GetSharedQueries(project: string, path: string, docType: string = ''): Promise<any> {
|
|
195
252
|
let url;
|
|
196
253
|
try {
|
|
197
|
-
|
|
254
|
+
const normalizedPath = this.normalizeSharedQueriesPath(path);
|
|
255
|
+
if (normalizedPath === '')
|
|
198
256
|
url = `${this.orgUrl}${project}/_apis/wit/queries/Shared%20Queries?$depth=2&$expand=all`;
|
|
199
|
-
else url = `${this.orgUrl}${project}/_apis/wit/queries/${
|
|
257
|
+
else url = `${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=2&$expand=all`;
|
|
200
258
|
let queries: any = await TFSServices.getItemContent(url, this.token);
|
|
201
259
|
logger.debug(`doctype: ${docType}`);
|
|
202
260
|
const normalizedDocType = (docType || '').toLowerCase();
|
|
@@ -404,6 +462,13 @@ export default class TicketsDataProvider {
|
|
|
404
462
|
knownBugsQueryTree: knownBugsFetch?.result ?? null,
|
|
405
463
|
};
|
|
406
464
|
}
|
|
465
|
+
case 'historical-query':
|
|
466
|
+
case 'historical': {
|
|
467
|
+
const { tree1 } = await this.structureAllQueryPath(queriesWithChildren);
|
|
468
|
+
return {
|
|
469
|
+
historicalQueryTree: tree1 ? [tree1] : [],
|
|
470
|
+
};
|
|
471
|
+
}
|
|
407
472
|
default:
|
|
408
473
|
break;
|
|
409
474
|
}
|
|
@@ -1490,6 +1555,658 @@ export default class TicketsDataProvider {
|
|
|
1490
1555
|
return await this.GetQueryResultsByWiqlHref(wiql.href, project);
|
|
1491
1556
|
}
|
|
1492
1557
|
|
|
1558
|
+
private normalizeHistoricalAsOf(value: string): string {
|
|
1559
|
+
const raw = String(value || '').trim();
|
|
1560
|
+
if (!raw) {
|
|
1561
|
+
throw new Error('asOf date-time is required');
|
|
1562
|
+
}
|
|
1563
|
+
const parsed = new Date(raw);
|
|
1564
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
1565
|
+
throw new Error(`Invalid date-time value: ${raw}`);
|
|
1566
|
+
}
|
|
1567
|
+
return parsed.toISOString();
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private normalizeHistoricalCompareValue(value: unknown): string {
|
|
1571
|
+
if (value == null) {
|
|
1572
|
+
return '';
|
|
1573
|
+
}
|
|
1574
|
+
if (typeof value === 'string') {
|
|
1575
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
1576
|
+
}
|
|
1577
|
+
if (Array.isArray(value)) {
|
|
1578
|
+
return value
|
|
1579
|
+
.map((item) => this.normalizeHistoricalCompareValue(item))
|
|
1580
|
+
.filter((item) => item !== '')
|
|
1581
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1582
|
+
.join('; ');
|
|
1583
|
+
}
|
|
1584
|
+
if (typeof value === 'object') {
|
|
1585
|
+
const fallback = value as Record<string, unknown>;
|
|
1586
|
+
if (typeof fallback.displayName === 'string') {
|
|
1587
|
+
return this.normalizeHistoricalCompareValue(fallback.displayName);
|
|
1588
|
+
}
|
|
1589
|
+
if (typeof fallback.name === 'string') {
|
|
1590
|
+
return this.normalizeHistoricalCompareValue(fallback.name);
|
|
1591
|
+
}
|
|
1592
|
+
return this.normalizeHistoricalCompareValue(JSON.stringify(value));
|
|
1593
|
+
}
|
|
1594
|
+
return String(value).trim();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
private normalizeTestPhaseValue(value: unknown): string {
|
|
1598
|
+
const rendered = this.normalizeHistoricalCompareValue(value);
|
|
1599
|
+
if (!rendered) {
|
|
1600
|
+
return '';
|
|
1601
|
+
}
|
|
1602
|
+
const parts = rendered
|
|
1603
|
+
.split(/[;,]/)
|
|
1604
|
+
.map((part) => part.trim())
|
|
1605
|
+
.filter((part) => part.length > 0);
|
|
1606
|
+
if (parts.length <= 1) {
|
|
1607
|
+
return rendered;
|
|
1608
|
+
}
|
|
1609
|
+
const unique = Array.from(new Set(parts));
|
|
1610
|
+
unique.sort((a, b) => a.localeCompare(b));
|
|
1611
|
+
return unique.join('; ');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
private isTestCaseType(workItemType: string): boolean {
|
|
1615
|
+
const normalized = String(workItemType || '')
|
|
1616
|
+
.trim()
|
|
1617
|
+
.toLowerCase();
|
|
1618
|
+
return normalized === 'test case' || normalized === 'testcase';
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
private toHistoricalRevision(value: unknown): number | null {
|
|
1622
|
+
const n = Number(value);
|
|
1623
|
+
if (!Number.isFinite(n)) {
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
return n;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
private extractHistoricalWorkItemIds(queryResult: any): number[] {
|
|
1630
|
+
const ids = new Set<number>();
|
|
1631
|
+
|
|
1632
|
+
const pushId = (candidate: unknown) => {
|
|
1633
|
+
const n = Number(candidate);
|
|
1634
|
+
if (Number.isFinite(n)) {
|
|
1635
|
+
ids.add(n);
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const workItems = Array.isArray(queryResult?.workItems) ? queryResult.workItems : [];
|
|
1640
|
+
for (const workItem of workItems) {
|
|
1641
|
+
pushId(workItem?.id);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const workItemRelations = Array.isArray(queryResult?.workItemRelations)
|
|
1645
|
+
? queryResult.workItemRelations
|
|
1646
|
+
: [];
|
|
1647
|
+
for (const relation of workItemRelations) {
|
|
1648
|
+
pushId(relation?.source?.id);
|
|
1649
|
+
pushId(relation?.target?.id);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
return Array.from(ids.values()).sort((a, b) => a - b);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
private appendAsOfToWiql(wiql: string, asOfIso: string): string {
|
|
1656
|
+
const raw = String(wiql || '').trim();
|
|
1657
|
+
if (!raw) {
|
|
1658
|
+
return '';
|
|
1659
|
+
}
|
|
1660
|
+
const withoutAsOf = raw.replace(/\s+ASOF\s+'[^']*'/i, '');
|
|
1661
|
+
return `${withoutAsOf} ASOF '${asOfIso}'`;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
private appendApiVersion(url: string, apiVersion: string | null): string {
|
|
1665
|
+
if (!apiVersion) {
|
|
1666
|
+
return url;
|
|
1667
|
+
}
|
|
1668
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
1669
|
+
return `${url}${separator}api-version=${encodeURIComponent(apiVersion)}`;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
private shouldRetryHistoricalWithLowerVersion(error: any): boolean {
|
|
1673
|
+
const status = Number(error?.response?.status || 0);
|
|
1674
|
+
if (status === 401 || status === 403) {
|
|
1675
|
+
return false;
|
|
1676
|
+
}
|
|
1677
|
+
if (status >= 500) {
|
|
1678
|
+
return true;
|
|
1679
|
+
}
|
|
1680
|
+
if ([400, 404, 405, 406, 410].includes(status)) {
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
const message = String(error?.response?.data?.message || error?.message || '');
|
|
1684
|
+
return /api[- ]?version/i.test(message);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
private async withHistoricalApiVersionFallback<T>(
|
|
1688
|
+
operation: string,
|
|
1689
|
+
task: (apiVersion: string | null) => Promise<T>,
|
|
1690
|
+
): Promise<{ apiVersion: string | null; result: T }> {
|
|
1691
|
+
let lastError: any = null;
|
|
1692
|
+
for (const apiVersion of HISTORICAL_WIT_API_VERSIONS) {
|
|
1693
|
+
try {
|
|
1694
|
+
const result = await task(apiVersion);
|
|
1695
|
+
return { apiVersion, result };
|
|
1696
|
+
} catch (error: any) {
|
|
1697
|
+
lastError = error;
|
|
1698
|
+
const status = Number(error?.response?.status || 0);
|
|
1699
|
+
const apiVersionLabel = apiVersion || 'default';
|
|
1700
|
+
logger.warn(
|
|
1701
|
+
`[${operation}] failed with api-version=${apiVersionLabel}${status ? ` (status ${status})` : ''}`,
|
|
1702
|
+
);
|
|
1703
|
+
if (
|
|
1704
|
+
!this.shouldRetryHistoricalWithLowerVersion(error) ||
|
|
1705
|
+
apiVersion === HISTORICAL_WIT_API_VERSIONS[HISTORICAL_WIT_API_VERSIONS.length - 1]
|
|
1706
|
+
) {
|
|
1707
|
+
throw error;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
throw lastError || new Error(`[${operation}] Failed to execute historical request`);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
private chunkHistoricalWorkItemIds(ids: number[]): number[][] {
|
|
1715
|
+
const chunks: number[][] = [];
|
|
1716
|
+
for (let i = 0; i < ids.length; i += HISTORICAL_BATCH_MAX_IDS) {
|
|
1717
|
+
chunks.push(ids.slice(i, i + HISTORICAL_BATCH_MAX_IDS));
|
|
1718
|
+
}
|
|
1719
|
+
return chunks;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private normalizeHistoricalQueryPath(path: string): string {
|
|
1723
|
+
const rawPath = String(path || '').trim();
|
|
1724
|
+
const normalizedRoot = rawPath.toLowerCase();
|
|
1725
|
+
if (rawPath === '' || normalizedRoot === 'shared' || normalizedRoot === 'shared queries') {
|
|
1726
|
+
return 'Shared%20Queries';
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const segments = rawPath
|
|
1730
|
+
.replace(/\\/g, '/')
|
|
1731
|
+
.split('/')
|
|
1732
|
+
.filter((segment) => segment.trim() !== '')
|
|
1733
|
+
.map((segment) => {
|
|
1734
|
+
try {
|
|
1735
|
+
return encodeURIComponent(decodeURIComponent(segment));
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
return encodeURIComponent(segment);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
return segments.join('/');
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
private normalizeHistoricalQueryRoot(root: any): any {
|
|
1744
|
+
if (!root) return root;
|
|
1745
|
+
if (Array.isArray(root?.children) || typeof root?.isFolder === 'boolean') {
|
|
1746
|
+
return root;
|
|
1747
|
+
}
|
|
1748
|
+
if (Array.isArray(root?.value)) {
|
|
1749
|
+
return {
|
|
1750
|
+
id: 'root',
|
|
1751
|
+
name: 'Shared Queries',
|
|
1752
|
+
isFolder: true,
|
|
1753
|
+
children: root.value,
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1756
|
+
return root;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
private historicalErrorMessage(error: any): string {
|
|
1760
|
+
return String(error?.response?.data?.message || error?.message || error || '').trim();
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
private isHistoricalMissingWorkItemError(error: any, workItemId: number): boolean {
|
|
1764
|
+
const status = Number(error?.response?.status || error?.status || 0);
|
|
1765
|
+
const message = this.historicalErrorMessage(error);
|
|
1766
|
+
if (!message) {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const mentionsWorkItem = /\bwork\s*item\b/i.test(message);
|
|
1771
|
+
const mentionsId = message.includes(String(workItemId));
|
|
1772
|
+
const missingPattern = /does not exist(?: at time)?|not found|has been deleted|was deleted/i;
|
|
1773
|
+
const hasMissingIndicator = missingPattern.test(message);
|
|
1774
|
+
|
|
1775
|
+
if (!mentionsWorkItem || !mentionsId || !hasMissingIndicator) {
|
|
1776
|
+
return false;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (/at time/i.test(message)) {
|
|
1780
|
+
return true;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
return status === 404 || status === 410;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
private async fetchHistoricalWorkItemsBatch(
|
|
1787
|
+
project: string,
|
|
1788
|
+
ids: number[],
|
|
1789
|
+
asOf: string,
|
|
1790
|
+
apiVersion: string | null,
|
|
1791
|
+
): Promise<{ items: any[]; skippedWorkItemIds: number[] }> {
|
|
1792
|
+
const workItemsBatchUrl = this.appendApiVersion(
|
|
1793
|
+
`${this.orgUrl}${project}/_apis/wit/workitemsbatch`,
|
|
1794
|
+
apiVersion,
|
|
1795
|
+
);
|
|
1796
|
+
const idChunks = this.chunkHistoricalWorkItemIds(ids);
|
|
1797
|
+
try {
|
|
1798
|
+
const batchResponses = await Promise.all(
|
|
1799
|
+
idChunks.map((idChunk) =>
|
|
1800
|
+
this.limit(() =>
|
|
1801
|
+
TFSServices.getItemContent(workItemsBatchUrl, this.token, 'post', {
|
|
1802
|
+
ids: idChunk,
|
|
1803
|
+
asOf,
|
|
1804
|
+
$expand: 'Relations',
|
|
1805
|
+
fields: HISTORICAL_WORK_ITEM_FIELDS,
|
|
1806
|
+
}),
|
|
1807
|
+
),
|
|
1808
|
+
),
|
|
1809
|
+
);
|
|
1810
|
+
return {
|
|
1811
|
+
items: batchResponses.flatMap((batch) => (Array.isArray(batch?.value) ? batch.value : [])),
|
|
1812
|
+
skippedWorkItemIds: [],
|
|
1813
|
+
};
|
|
1814
|
+
} catch (error: any) {
|
|
1815
|
+
logger.warn(
|
|
1816
|
+
`[historical-workitems] workitemsbatch failed${
|
|
1817
|
+
apiVersion ? ` (api-version=${apiVersion})` : ''
|
|
1818
|
+
}, falling back to per-item retrieval: ${error?.message || error}`,
|
|
1819
|
+
);
|
|
1820
|
+
const asOfParam = encodeURIComponent(asOf);
|
|
1821
|
+
const responses = await Promise.all(
|
|
1822
|
+
ids.map((id) =>
|
|
1823
|
+
this.limit(async () => {
|
|
1824
|
+
const itemUrl = this.appendApiVersion(
|
|
1825
|
+
`${this.orgUrl}${project}/_apis/wit/workitems/${id}?$expand=Relations&asOf=${asOfParam}`,
|
|
1826
|
+
apiVersion,
|
|
1827
|
+
);
|
|
1828
|
+
try {
|
|
1829
|
+
const item = await TFSServices.getItemContent(itemUrl, this.token);
|
|
1830
|
+
return { id, item, skipped: false };
|
|
1831
|
+
} catch (itemError: any) {
|
|
1832
|
+
if (this.isHistoricalMissingWorkItemError(itemError, id)) {
|
|
1833
|
+
logger.warn(
|
|
1834
|
+
`[historical-workitems] skipping work item ${id} for asOf ${asOf}: ${this.historicalErrorMessage(itemError)}`,
|
|
1835
|
+
);
|
|
1836
|
+
return { id, item: null, skipped: true };
|
|
1837
|
+
}
|
|
1838
|
+
throw itemError;
|
|
1839
|
+
}
|
|
1840
|
+
}),
|
|
1841
|
+
),
|
|
1842
|
+
);
|
|
1843
|
+
return {
|
|
1844
|
+
items: responses
|
|
1845
|
+
.filter((entry) => !entry.skipped && entry.item && typeof entry.item === 'object')
|
|
1846
|
+
.map((entry) => entry.item),
|
|
1847
|
+
skippedWorkItemIds: responses.filter((entry) => entry.skipped).map((entry) => entry.id),
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
private async executeHistoricalQueryAtAsOf(
|
|
1853
|
+
project: string,
|
|
1854
|
+
queryId: string,
|
|
1855
|
+
asOfIso: string,
|
|
1856
|
+
): Promise<{ queryDefinition: any; queryResult: any; apiVersion: string | null }> {
|
|
1857
|
+
const { apiVersion, result } = await this.withHistoricalApiVersionFallback(
|
|
1858
|
+
'historical-query-execution',
|
|
1859
|
+
async (resolvedApiVersion) => {
|
|
1860
|
+
const queryDefUrl = this.appendApiVersion(
|
|
1861
|
+
`${this.orgUrl}${project}/_apis/wit/queries/${encodeURIComponent(queryId)}?$expand=all`,
|
|
1862
|
+
resolvedApiVersion,
|
|
1863
|
+
);
|
|
1864
|
+
const queryDefinition = await TFSServices.getItemContent(queryDefUrl, this.token);
|
|
1865
|
+
|
|
1866
|
+
const wiqlText = String(queryDefinition?.wiql || '').trim();
|
|
1867
|
+
let queryResult: any = null;
|
|
1868
|
+
try {
|
|
1869
|
+
if (!wiqlText) {
|
|
1870
|
+
throw new Error(`Could not resolve WIQL text for query ${queryId}`);
|
|
1871
|
+
}
|
|
1872
|
+
const wiqlWithAsOf = this.appendAsOfToWiql(wiqlText, asOfIso);
|
|
1873
|
+
if (!wiqlWithAsOf) {
|
|
1874
|
+
throw new Error(`Could not build WIQL for historical query ${queryId}`);
|
|
1875
|
+
}
|
|
1876
|
+
const executeUrl = this.appendApiVersion(
|
|
1877
|
+
`${this.orgUrl}${project}/_apis/wit/wiql?$top=2147483646&timePrecision=true`,
|
|
1878
|
+
resolvedApiVersion,
|
|
1879
|
+
);
|
|
1880
|
+
queryResult = await TFSServices.getItemContent(executeUrl, this.token, 'post', {
|
|
1881
|
+
query: wiqlWithAsOf,
|
|
1882
|
+
});
|
|
1883
|
+
} catch (inlineWiqlError: any) {
|
|
1884
|
+
logger.warn(
|
|
1885
|
+
`[historical-query-execution] inline WIQL failed for query ${queryId}${
|
|
1886
|
+
resolvedApiVersion ? ` (api-version=${resolvedApiVersion})` : ''
|
|
1887
|
+
}, trying WIQL-by-id fallback: ${inlineWiqlError?.message || inlineWiqlError}`,
|
|
1888
|
+
);
|
|
1889
|
+
const executeByIdUrl = this.appendApiVersion(
|
|
1890
|
+
`${this.orgUrl}${project}/_apis/wit/wiql/${encodeURIComponent(
|
|
1891
|
+
queryId,
|
|
1892
|
+
)}?$top=2147483646&timePrecision=true&asOf=${encodeURIComponent(asOfIso)}`,
|
|
1893
|
+
resolvedApiVersion,
|
|
1894
|
+
);
|
|
1895
|
+
queryResult = await TFSServices.getItemContent(executeByIdUrl, this.token);
|
|
1896
|
+
}
|
|
1897
|
+
return { queryDefinition, queryResult };
|
|
1898
|
+
},
|
|
1899
|
+
);
|
|
1900
|
+
return { ...result, apiVersion };
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
private toHistoricalWorkItemSnapshot(project: string, workItem: any): HistoricalWorkItemSnapshot {
|
|
1904
|
+
const fields = (workItem?.fields || {}) as Record<string, unknown>;
|
|
1905
|
+
const id = Number(workItem?.id || fields['System.Id'] || 0);
|
|
1906
|
+
const workItemType = this.normalizeHistoricalCompareValue(fields['System.WorkItemType']);
|
|
1907
|
+
const testPhaseRaw =
|
|
1908
|
+
fields['Elisra.TestPhase'] ??
|
|
1909
|
+
fields['Custom.TestPhase'] ??
|
|
1910
|
+
fields['Elisra.Testphase'] ??
|
|
1911
|
+
fields['Custom.Testphase'];
|
|
1912
|
+
const relatedLinkCount = Array.isArray(workItem?.relations) ? workItem.relations.length : 0;
|
|
1913
|
+
|
|
1914
|
+
return {
|
|
1915
|
+
id,
|
|
1916
|
+
workItemType,
|
|
1917
|
+
title: this.normalizeHistoricalCompareValue(fields['System.Title']),
|
|
1918
|
+
state: this.normalizeHistoricalCompareValue(fields['System.State']),
|
|
1919
|
+
areaPath: this.normalizeHistoricalCompareValue(fields['System.AreaPath']),
|
|
1920
|
+
iterationPath: this.normalizeHistoricalCompareValue(fields['System.IterationPath']),
|
|
1921
|
+
versionId: this.toHistoricalRevision(workItem?.rev ?? fields['System.Rev']),
|
|
1922
|
+
versionTimestamp: this.normalizeHistoricalCompareValue(fields['System.ChangedDate']),
|
|
1923
|
+
description: this.normalizeHistoricalCompareValue(fields['System.Description']),
|
|
1924
|
+
steps: this.normalizeHistoricalCompareValue(fields['Microsoft.VSTS.TCM.Steps']),
|
|
1925
|
+
testPhase: this.normalizeTestPhaseValue(testPhaseRaw),
|
|
1926
|
+
relatedLinkCount,
|
|
1927
|
+
workItemUrl: `${this.orgUrl}${project}/_workitems/edit/${id}`,
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
private async getHistoricalSnapshot(
|
|
1932
|
+
project: string,
|
|
1933
|
+
queryId: string,
|
|
1934
|
+
asOfInput: string,
|
|
1935
|
+
): Promise<HistoricalSnapshotResult> {
|
|
1936
|
+
const asOf = this.normalizeHistoricalAsOf(asOfInput);
|
|
1937
|
+
const { queryDefinition, queryResult, apiVersion } = await this.executeHistoricalQueryAtAsOf(
|
|
1938
|
+
project,
|
|
1939
|
+
queryId,
|
|
1940
|
+
asOf,
|
|
1941
|
+
);
|
|
1942
|
+
const ids = this.extractHistoricalWorkItemIds(queryResult);
|
|
1943
|
+
|
|
1944
|
+
if (ids.length === 0) {
|
|
1945
|
+
return {
|
|
1946
|
+
queryId,
|
|
1947
|
+
queryName: String(queryDefinition?.name || queryId),
|
|
1948
|
+
asOf,
|
|
1949
|
+
total: 0,
|
|
1950
|
+
rows: [],
|
|
1951
|
+
snapshotMap: new Map<number, HistoricalWorkItemSnapshot>(),
|
|
1952
|
+
skippedWorkItemIds: [],
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const { items: values, skippedWorkItemIds } = await this.fetchHistoricalWorkItemsBatch(
|
|
1957
|
+
project,
|
|
1958
|
+
ids,
|
|
1959
|
+
asOf,
|
|
1960
|
+
apiVersion,
|
|
1961
|
+
);
|
|
1962
|
+
const rows = values
|
|
1963
|
+
.map((workItem: any) => this.toHistoricalWorkItemSnapshot(project, workItem))
|
|
1964
|
+
.sort((a: HistoricalWorkItemSnapshot, b: HistoricalWorkItemSnapshot) => a.id - b.id);
|
|
1965
|
+
const snapshotMap = new Map<number, HistoricalWorkItemSnapshot>();
|
|
1966
|
+
for (const row of rows) {
|
|
1967
|
+
snapshotMap.set(row.id, row);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
return {
|
|
1971
|
+
queryId,
|
|
1972
|
+
queryName: String(queryDefinition?.name || queryId),
|
|
1973
|
+
asOf,
|
|
1974
|
+
total: rows.length,
|
|
1975
|
+
rows,
|
|
1976
|
+
snapshotMap,
|
|
1977
|
+
skippedWorkItemIds,
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
private collectHistoricalQueries(root: any, parentPath = ''): HistoricalQueryListItem[] {
|
|
1982
|
+
const items: HistoricalQueryListItem[] = [];
|
|
1983
|
+
if (!root) {
|
|
1984
|
+
return items;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
const name = String(root?.name || '').trim();
|
|
1988
|
+
const currentPath = parentPath && name ? `${parentPath}/${name}` : name || parentPath;
|
|
1989
|
+
|
|
1990
|
+
if (!root?.isFolder) {
|
|
1991
|
+
const id = String(root?.id || '').trim();
|
|
1992
|
+
if (id) {
|
|
1993
|
+
items.push({
|
|
1994
|
+
id,
|
|
1995
|
+
queryName: name || id,
|
|
1996
|
+
path: parentPath || 'Shared Queries',
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
return items;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const children = Array.isArray(root?.children) ? root.children : [];
|
|
2003
|
+
for (const child of children) {
|
|
2004
|
+
items.push(...this.collectHistoricalQueries(child, currentPath || 'Shared Queries'));
|
|
2005
|
+
}
|
|
2006
|
+
return items;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Returns a flat list of shared queries for historical/as-of execution.
|
|
2011
|
+
*/
|
|
2012
|
+
async GetHistoricalQueries(project: string, path: string = 'shared'): Promise<HistoricalQueryListItem[]> {
|
|
2013
|
+
const normalizedPath = this.normalizeHistoricalQueryPath(path);
|
|
2014
|
+
// Azure DevOps WIT query tree endpoint enforces $depth range 0..2.
|
|
2015
|
+
const depth = 2;
|
|
2016
|
+
const { result: root } = await this.withHistoricalApiVersionFallback(
|
|
2017
|
+
'historical-queries-list',
|
|
2018
|
+
(apiVersion) => {
|
|
2019
|
+
const url = this.appendApiVersion(
|
|
2020
|
+
`${this.orgUrl}${project}/_apis/wit/queries/${normalizedPath}?$depth=${depth}&$expand=all`,
|
|
2021
|
+
apiVersion,
|
|
2022
|
+
);
|
|
2023
|
+
return TFSServices.getItemContent(url, this.token);
|
|
2024
|
+
},
|
|
2025
|
+
);
|
|
2026
|
+
const normalizedRoot = this.normalizeHistoricalQueryRoot(root);
|
|
2027
|
+
const items = this.collectHistoricalQueries(normalizedRoot).filter((query) => query.id !== '');
|
|
2028
|
+
items.sort((a, b) => {
|
|
2029
|
+
const byPath = a.path.localeCompare(b.path);
|
|
2030
|
+
if (byPath !== 0) return byPath;
|
|
2031
|
+
return a.queryName.localeCompare(b.queryName);
|
|
2032
|
+
});
|
|
2033
|
+
return items;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/**
|
|
2037
|
+
* Runs a shared query as-of a specific date-time and returns a flat work-item table snapshot.
|
|
2038
|
+
*/
|
|
2039
|
+
async GetHistoricalQueryResults(queryId: string, project: string, asOfInput: string): Promise<any> {
|
|
2040
|
+
const snapshot = await this.getHistoricalSnapshot(project, queryId, asOfInput);
|
|
2041
|
+
return {
|
|
2042
|
+
queryId: snapshot.queryId,
|
|
2043
|
+
queryName: snapshot.queryName,
|
|
2044
|
+
asOf: snapshot.asOf,
|
|
2045
|
+
total: snapshot.total,
|
|
2046
|
+
skippedWorkItemsCount: snapshot.skippedWorkItemIds.length,
|
|
2047
|
+
rows: snapshot.rows.map((row) => ({
|
|
2048
|
+
id: row.id,
|
|
2049
|
+
workItemType: row.workItemType,
|
|
2050
|
+
title: row.title,
|
|
2051
|
+
state: row.state,
|
|
2052
|
+
areaPath: row.areaPath,
|
|
2053
|
+
iterationPath: row.iterationPath,
|
|
2054
|
+
versionId: row.versionId,
|
|
2055
|
+
versionTimestamp: row.versionTimestamp,
|
|
2056
|
+
workItemUrl: row.workItemUrl,
|
|
2057
|
+
})),
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Compares a shared query between two date-time baselines using the feature's noise-control fields.
|
|
2063
|
+
*/
|
|
2064
|
+
async CompareHistoricalQueryResults(
|
|
2065
|
+
queryId: string,
|
|
2066
|
+
project: string,
|
|
2067
|
+
baselineAsOfInput: string,
|
|
2068
|
+
compareToAsOfInput: string,
|
|
2069
|
+
): Promise<any> {
|
|
2070
|
+
const [baseline, compareTo] = await Promise.all([
|
|
2071
|
+
this.getHistoricalSnapshot(project, queryId, baselineAsOfInput),
|
|
2072
|
+
this.getHistoricalSnapshot(project, queryId, compareToAsOfInput),
|
|
2073
|
+
]);
|
|
2074
|
+
|
|
2075
|
+
const allIds = new Set<number>([
|
|
2076
|
+
...Array.from(baseline.snapshotMap.keys()),
|
|
2077
|
+
...Array.from(compareTo.snapshotMap.keys()),
|
|
2078
|
+
]);
|
|
2079
|
+
const sortedIds = Array.from(allIds.values()).sort((a, b) => a - b);
|
|
2080
|
+
|
|
2081
|
+
const rows = sortedIds.map((id) => {
|
|
2082
|
+
const baselineRow = baseline.snapshotMap.get(id) || null;
|
|
2083
|
+
const compareToRow = compareTo.snapshotMap.get(id) || null;
|
|
2084
|
+
const workItemType = compareToRow?.workItemType || baselineRow?.workItemType || '';
|
|
2085
|
+
const isTestCase = this.isTestCaseType(workItemType);
|
|
2086
|
+
|
|
2087
|
+
if (baselineRow && !compareToRow) {
|
|
2088
|
+
return {
|
|
2089
|
+
id,
|
|
2090
|
+
workItemType,
|
|
2091
|
+
title: baselineRow.title,
|
|
2092
|
+
baselineRevisionId: baselineRow.versionId,
|
|
2093
|
+
compareToRevisionId: null,
|
|
2094
|
+
compareStatus: 'Deleted',
|
|
2095
|
+
changedFields: [],
|
|
2096
|
+
differences: [],
|
|
2097
|
+
workItemUrl: baselineRow.workItemUrl,
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (!baselineRow && compareToRow) {
|
|
2102
|
+
return {
|
|
2103
|
+
id,
|
|
2104
|
+
workItemType,
|
|
2105
|
+
title: compareToRow.title,
|
|
2106
|
+
baselineRevisionId: null,
|
|
2107
|
+
compareToRevisionId: compareToRow.versionId,
|
|
2108
|
+
compareStatus: 'Added',
|
|
2109
|
+
changedFields: [],
|
|
2110
|
+
differences: [],
|
|
2111
|
+
workItemUrl: compareToRow.workItemUrl,
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
const safeBaseline = baselineRow as HistoricalWorkItemSnapshot;
|
|
2116
|
+
const safeCompareTo = compareToRow as HistoricalWorkItemSnapshot;
|
|
2117
|
+
const changedFields: string[] = [];
|
|
2118
|
+
|
|
2119
|
+
if (safeBaseline.description !== safeCompareTo.description) {
|
|
2120
|
+
changedFields.push('Description');
|
|
2121
|
+
}
|
|
2122
|
+
if (safeBaseline.title !== safeCompareTo.title) {
|
|
2123
|
+
changedFields.push('Title');
|
|
2124
|
+
}
|
|
2125
|
+
if (safeBaseline.state !== safeCompareTo.state) {
|
|
2126
|
+
changedFields.push('State');
|
|
2127
|
+
}
|
|
2128
|
+
if (isTestCase && safeBaseline.steps !== safeCompareTo.steps) {
|
|
2129
|
+
changedFields.push('Steps');
|
|
2130
|
+
}
|
|
2131
|
+
if (safeBaseline.testPhase !== safeCompareTo.testPhase) {
|
|
2132
|
+
changedFields.push('Test Phase');
|
|
2133
|
+
}
|
|
2134
|
+
if (isTestCase && safeBaseline.relatedLinkCount !== safeCompareTo.relatedLinkCount) {
|
|
2135
|
+
changedFields.push('Related Link Count');
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const differences = changedFields.map((field) => {
|
|
2139
|
+
switch (field) {
|
|
2140
|
+
case 'Description':
|
|
2141
|
+
return { field, baseline: safeBaseline.description, compareTo: safeCompareTo.description };
|
|
2142
|
+
case 'Title':
|
|
2143
|
+
return { field, baseline: safeBaseline.title, compareTo: safeCompareTo.title };
|
|
2144
|
+
case 'State':
|
|
2145
|
+
return { field, baseline: safeBaseline.state, compareTo: safeCompareTo.state };
|
|
2146
|
+
case 'Steps':
|
|
2147
|
+
return { field, baseline: safeBaseline.steps, compareTo: safeCompareTo.steps };
|
|
2148
|
+
case 'Test Phase':
|
|
2149
|
+
return { field, baseline: safeBaseline.testPhase, compareTo: safeCompareTo.testPhase };
|
|
2150
|
+
case 'Related Link Count':
|
|
2151
|
+
return {
|
|
2152
|
+
field,
|
|
2153
|
+
baseline: String(safeBaseline.relatedLinkCount),
|
|
2154
|
+
compareTo: String(safeCompareTo.relatedLinkCount),
|
|
2155
|
+
};
|
|
2156
|
+
default:
|
|
2157
|
+
return { field, baseline: '', compareTo: '' };
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
return {
|
|
2162
|
+
id,
|
|
2163
|
+
workItemType,
|
|
2164
|
+
title: safeCompareTo.title || safeBaseline.title,
|
|
2165
|
+
baselineRevisionId: safeBaseline.versionId,
|
|
2166
|
+
compareToRevisionId: safeCompareTo.versionId,
|
|
2167
|
+
compareStatus: changedFields.length > 0 ? 'Changed' : 'No changes',
|
|
2168
|
+
changedFields,
|
|
2169
|
+
differences,
|
|
2170
|
+
workItemUrl: safeCompareTo.workItemUrl || safeBaseline.workItemUrl,
|
|
2171
|
+
};
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
const summary = rows.reduce(
|
|
2175
|
+
(acc, row) => {
|
|
2176
|
+
if (row.compareStatus === 'Added') acc.addedCount += 1;
|
|
2177
|
+
else if (row.compareStatus === 'Deleted') acc.deletedCount += 1;
|
|
2178
|
+
else if (row.compareStatus === 'Changed') acc.changedCount += 1;
|
|
2179
|
+
else acc.noChangeCount += 1;
|
|
2180
|
+
return acc;
|
|
2181
|
+
},
|
|
2182
|
+
{ addedCount: 0, deletedCount: 0, changedCount: 0, noChangeCount: 0 },
|
|
2183
|
+
);
|
|
2184
|
+
|
|
2185
|
+
return {
|
|
2186
|
+
queryId,
|
|
2187
|
+
queryName: compareTo.queryName || baseline.queryName || queryId,
|
|
2188
|
+
baseline: {
|
|
2189
|
+
asOf: baseline.asOf,
|
|
2190
|
+
total: baseline.total,
|
|
2191
|
+
},
|
|
2192
|
+
compareTo: {
|
|
2193
|
+
asOf: compareTo.asOf,
|
|
2194
|
+
total: compareTo.total,
|
|
2195
|
+
},
|
|
2196
|
+
summary: {
|
|
2197
|
+
...summary,
|
|
2198
|
+
updatedCount: summary.changedCount,
|
|
2199
|
+
},
|
|
2200
|
+
skippedWorkItems: {
|
|
2201
|
+
baselineCount: baseline.skippedWorkItemIds.length,
|
|
2202
|
+
compareToCount: compareTo.skippedWorkItemIds.length,
|
|
2203
|
+
totalDistinct: new Set<number>([...baseline.skippedWorkItemIds, ...compareTo.skippedWorkItemIds])
|
|
2204
|
+
.size,
|
|
2205
|
+
},
|
|
2206
|
+
rows,
|
|
2207
|
+
};
|
|
2208
|
+
}
|
|
2209
|
+
|
|
1493
2210
|
async PopulateWorkItemsByIds(workItemsArray: any[] = [], projectName: string = ''): Promise<any[]> {
|
|
1494
2211
|
let url = `${this.orgUrl}${projectName}/_apis/wit/workitemsbatch`;
|
|
1495
2212
|
let res: any[] = [];
|