devops-plugin-kit 0.1.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/@danieli-automation/create-devops-plugin/package.json +30 -0
- package/@danieli-automation/create-devops-plugin/src/cli.ts +136 -0
- package/@danieli-automation/create-devops-plugin/src/index.ts +37 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/apiIndexMock.ts +10 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/appHtml.ts +25 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/appStyles.ts +22 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/appTsx.ts +41 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/envExample.ts +11 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/gitignore.ts +13 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/index.ts +51 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/packageJson.ts +60 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/readme.ts +30 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/sdkMock.ts +11 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/testSetup.ts +16 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/tsconfigJson.ts +30 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/vitestConfig.ts +57 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/webpackAppConfig.ts +31 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/webpackCommonConfig.ts +116 -0
- package/@danieli-automation/create-devops-plugin/src/template/files/webpackConfig.ts +15 -0
- package/@danieli-automation/create-devops-plugin/src/template/files.ts +1 -0
- package/@danieli-automation/create-devops-plugin/src/template/fs.ts +85 -0
- package/@danieli-automation/create-devops-plugin/src/template/manifest.ts +40 -0
- package/@danieli-automation/create-devops-plugin/src/template/npm.ts +22 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/fonts/AzDevMDL2.woff +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/fonts/bowtie.woff2 +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fabric-icons.woff +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fluent-filled-v1.1.293.woff2 +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fluent-regular-v1.1.293.woff2 +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/images/DigiMetLogo.jpeg +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/images/danieliAutomation.png +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/images/danieliAutomationBlack.jpg +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/images/danieli_digi_met_logo.jpeg +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/static/images/logoSmallpng.png +0 -0
- package/@danieli-automation/create-devops-plugin/src/template/types.ts +14 -0
- package/@danieli-automation/create-devops-plugin/src/template/utils.ts +22 -0
- package/@danieli-automation/create-devops-plugin/tsconfig.json +8 -0
- package/@danieli-automation/devops-plugin-core/package.json +27 -0
- package/@danieli-automation/devops-plugin-core/src/core/azureClients.ts +18 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/createStore.ts +65 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useCrossTeamSprintInstance.ts +145 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useTaskOrder.ts +125 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useWorkItemOrder.ts +86 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/index.ts +13 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/keys.ts +31 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/instance.ts +184 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/taskOrder.ts +59 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/workItemOrder.ts +60 -0
- package/@danieli-automation/devops-plugin-core/src/core/storage/stores.ts +18 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/AdoWorkItemType.ts +18 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/KVStoreType.ts +1 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/ScopeType.ts +1 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/SelectedProjectType.ts +8 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/SortConfigType.ts +2 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/instance/CreateInstanceInputType.ts +10 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/instance/CrossSprintInstanceType.ts +20 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/instance/DefaultInstanceType.ts +12 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/instance/InstanceRowType.ts +18 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/instance/UpdateInstanceInputType.ts +10 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/taskOrder/TaskOrderMapType.ts +3 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/taskOrder/TaskOrderType.ts +1 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/workItemOrder/WorkItemOrderMapType.ts +3 -0
- package/@danieli-automation/devops-plugin-core/src/core/types/workItemOrder/WorkItemOrderType.ts +1 -0
- package/@danieli-automation/devops-plugin-core/src/index.ts +1 -0
- package/@danieli-automation/devops-plugin-core/src/pluginCore.ts +12 -0
- package/@danieli-automation/devops-plugin-core/tsconfig.json +16 -0
- package/@danieli-automation/devops-plugin-features/package.json +31 -0
- package/@danieli-automation/devops-plugin-features/src/app/stores/useUIStore.ts +12 -0
- package/@danieli-automation/devops-plugin-features/src/app/utils/date.ts +9 -0
- package/@danieli-automation/devops-plugin-features/src/app/utils/global.ts +9 -0
- package/@danieli-automation/devops-plugin-features/src/core/azureClients.ts +12 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/constants/InstanceConstant.ts +5 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/hooks/useInstancePermission.ts +127 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/stores/__tests__/useInstanceStore.test.ts +25 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/stores/types/InstanceStoreType.ts +7 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/stores/useInstanceStore.ts +9 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/types/CrossSprintInstanceType.ts +20 -0
- package/@danieli-automation/devops-plugin-features/src/features/instances/utils/instance.ts +55 -0
- package/@danieli-automation/devops-plugin-features/src/features/iterations/api/iterations.ts +298 -0
- package/@danieli-automation/devops-plugin-features/src/features/iterations/services/IterationService.ts +215 -0
- package/@danieli-automation/devops-plugin-features/src/features/iterations/types/IterationInfoType.ts +15 -0
- package/@danieli-automation/devops-plugin-features/src/features/iterations/utils/__tests__/iteration.test.ts +39 -0
- package/@danieli-automation/devops-plugin-features/src/features/iterations/utils/iteration.ts +132 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/api/projects.ts +79 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/api/teams.ts +29 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/api/users.ts +124 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/services/ProjectService.ts +80 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/types/SelectedProjectType.ts +8 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/types/TeamMemberType.ts +7 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/types/TeamRowSeedType.ts +6 -0
- package/@danieli-automation/devops-plugin-features/src/features/teams/types/UserSearchResultType.ts +8 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/api/states.ts +24 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/api/wiql.ts +146 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/api/workItems.ts +193 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/DefaultConsant.ts +43 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/FieldConstant.ts +27 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/StateConstant.ts +16 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/TypeConstant.ts +8 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/services/WIQLService.ts +101 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/services/WorkItemService.ts +54 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/types/AdoWorkItemType.ts +18 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/__tests__/filter.test.ts +87 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/__tests__/workItem.test.ts +116 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/filter.ts +189 -0
- package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/workItem.ts +245 -0
- package/@danieli-automation/devops-plugin-features/src/index.ts +0 -0
- package/@danieli-automation/devops-plugin-features/src/test/mocks/azure-devops-extension-api/Work.ts +5 -0
- package/@danieli-automation/devops-plugin-features/tsconfig.json +18 -0
- package/@danieli-automation/devops-plugin-features/vitest.config.ts +21 -0
- package/README.md +55 -0
- package/devops-plugin-kit-0.1.0.tgz +0 -0
- package/package.json +29 -0
- package/tsconfig.base.json +15 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { getClient } from "azure-devops-extension-api";
|
|
2
|
+
import { CoreRestClient, TeamContext } from "azure-devops-extension-api/Core";
|
|
3
|
+
import { TeamSettingsIteration } from "azure-devops-extension-api/Work";
|
|
4
|
+
import { witClient, workClient } from "core/azureClients";
|
|
5
|
+
import type { IterationInfoType } from "features/iterations/types/IterationInfoType";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fetches the list of iterations for a specific project and team.
|
|
9
|
+
*
|
|
10
|
+
* Uses the Azure DevOps WorkRestClient to retrieve team iterations and transforms
|
|
11
|
+
* the response into a more convenient format with ISO date strings.
|
|
12
|
+
*
|
|
13
|
+
* @param projectId - The unique identifier of the project
|
|
14
|
+
* @param teamId - The unique identifier of the team
|
|
15
|
+
* @returns A promise resolving to an array of IterationInfo objects
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchIterations(projectId: string, teamId: string): Promise<IterationInfoType[]> {
|
|
18
|
+
// Fetch TEAM iterations first (fast path)
|
|
19
|
+
let teamIterations: TeamSettingsIteration[] = [];
|
|
20
|
+
try {
|
|
21
|
+
// Resolve the correct team context (ids + names) for Work REST calls.
|
|
22
|
+
const teamContext = await resolveTeamContext(projectId, teamId);
|
|
23
|
+
// Pull team iterations (usually includes dates + paths).
|
|
24
|
+
teamIterations = await workClient().getTeamIterations(teamContext);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
// If team iterations fail, fall back to the classification tree.
|
|
27
|
+
console.warn("Failed to fetch team iterations:", { projectId, teamId, err });
|
|
28
|
+
teamIterations = [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Normalize/shape team iterations into IterationInfoType and keep only dated entries.
|
|
32
|
+
const fromTeam = teamIterations
|
|
33
|
+
.map((iter) => {
|
|
34
|
+
// Read dates from team iteration attributes.
|
|
35
|
+
const startRaw = iter.attributes?.startDate;
|
|
36
|
+
const finishRaw = iter.attributes?.finishDate;
|
|
37
|
+
// Normalize the path for WIQL compatibility.
|
|
38
|
+
const finalPath = normalizeIterationPath(String(iter.path ?? iter.name ?? ""));
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
// Use server id when available; fallback to normalized path for stability.
|
|
42
|
+
id: String(iter.id ?? finalPath) as any,
|
|
43
|
+
iterationId: undefined as any,
|
|
44
|
+
timeFrame: iter.attributes?.timeFrame,
|
|
45
|
+
name: String(iter.name ?? ""),
|
|
46
|
+
path: finalPath,
|
|
47
|
+
// Convert dates to ISO string format.
|
|
48
|
+
startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
|
|
49
|
+
finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
|
|
50
|
+
projectId,
|
|
51
|
+
teamId,
|
|
52
|
+
} as IterationInfoType;
|
|
53
|
+
})
|
|
54
|
+
.filter((iter) => {
|
|
55
|
+
// Skip undated iterations (folders or invalid entries).
|
|
56
|
+
if (!iter.startDate || !iter.finishDate) return false;
|
|
57
|
+
// Ensure dates parse cleanly.
|
|
58
|
+
const s = Date.parse(iter.startDate);
|
|
59
|
+
const f = Date.parse(iter.finishDate);
|
|
60
|
+
return Number.isFinite(s) && Number.isFinite(f);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// If we got valid iterations from the team API, return them.
|
|
64
|
+
if (fromTeam.length > 0) {
|
|
65
|
+
return dedupeAndSortIterations(fromTeam);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Fallback: classification tree (slower, but fills gaps)
|
|
69
|
+
const rootNodes = await witClient().getRootNodes(projectId, 1);
|
|
70
|
+
// Find the "Iteration" root node.
|
|
71
|
+
const iterationRoot = rootNodes.find((n: any) => n.structureType === 1);
|
|
72
|
+
if (!iterationRoot) return [];
|
|
73
|
+
|
|
74
|
+
// Fetch the classification nodes (tree of all iterations).
|
|
75
|
+
let topLevelNodes: any[] = [];
|
|
76
|
+
try {
|
|
77
|
+
const fetched = await witClient().getClassificationNodes(projectId, [iterationRoot.id], 100);
|
|
78
|
+
topLevelNodes = Array.isArray(fetched) ? fetched : [];
|
|
79
|
+
} catch {
|
|
80
|
+
const fetched = await witClient().getClassificationNodes(projectId, [iterationRoot.id], 1);
|
|
81
|
+
topLevelNodes = Array.isArray(fetched) ? fetched : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Walk the tree and collect iteration nodes.
|
|
85
|
+
const iterationNodes: any[] = [];
|
|
86
|
+
const collect = (node: any) => {
|
|
87
|
+
if (!node) return;
|
|
88
|
+
if (node.structureType === 1) iterationNodes.push(node);
|
|
89
|
+
if (Array.isArray(node.children)) {
|
|
90
|
+
for (const c of node.children) collect(c);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const n of topLevelNodes) collect(n);
|
|
94
|
+
|
|
95
|
+
if (iterationNodes.length === 0) return [];
|
|
96
|
+
|
|
97
|
+
// Map classification nodes into IterationInfoType and keep only dated entries.
|
|
98
|
+
const mapped: IterationInfoType[] = iterationNodes
|
|
99
|
+
.map((node: any) => {
|
|
100
|
+
// Normalize the raw node path for WIQL compatibility.
|
|
101
|
+
const nodePathRaw = String(node.path ?? node.name ?? "");
|
|
102
|
+
const finalPath = normalizeIterationPath(nodePathRaw);
|
|
103
|
+
// Read dates from classification node attributes.
|
|
104
|
+
const startRaw = node.attributes?.startDate;
|
|
105
|
+
const finishRaw = node.attributes?.finishDate;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
// Prefer node identifier/id; fallback to normalized path.
|
|
109
|
+
id: String(node.identifier ?? node.id ?? finalPath) as any,
|
|
110
|
+
iterationId: node.id,
|
|
111
|
+
timeFrame: undefined,
|
|
112
|
+
name: String(node.name ?? ""),
|
|
113
|
+
path: finalPath,
|
|
114
|
+
// Convert dates to ISO string format.
|
|
115
|
+
startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
|
|
116
|
+
finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
|
|
117
|
+
projectId,
|
|
118
|
+
teamId,
|
|
119
|
+
} as IterationInfoType;
|
|
120
|
+
})
|
|
121
|
+
.filter((iter) => {
|
|
122
|
+
// Skip undated iterations (folders or invalid entries).
|
|
123
|
+
if (!iter.startDate || !iter.finishDate) return false;
|
|
124
|
+
// Ensure dates parse cleanly.
|
|
125
|
+
const s = Date.parse(iter.startDate);
|
|
126
|
+
const f = Date.parse(iter.finishDate);
|
|
127
|
+
return Number.isFinite(s) && Number.isFinite(f);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Deduplicate by canonical path and sort by start date.
|
|
131
|
+
return dedupeAndSortIterations(mapped);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Fetches team iteration metadata for a project/team pair.
|
|
136
|
+
*
|
|
137
|
+
* Uses the Azure DevOps WorkRestClient to retrieve the team's iterations,
|
|
138
|
+
* normalizes paths, and filters out entries without valid dates.
|
|
139
|
+
*
|
|
140
|
+
* @param projectId - The unique identifier of the project
|
|
141
|
+
* @param teamId - The unique identifier of the team
|
|
142
|
+
* @returns A promise resolving to an array of IterationInfo objects
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchIterationsPerWorkItem(projectId: string, teamId: string): Promise<IterationInfoType[]> {
|
|
145
|
+
const teamContext = await resolveTeamContext(projectId, teamId);
|
|
146
|
+
const teamIterations = await workClient().getTeamIterations(teamContext);
|
|
147
|
+
|
|
148
|
+
const teamIterByPath = new Map<string, TeamSettingsIteration>();
|
|
149
|
+
for (const t of teamIterations) {
|
|
150
|
+
if (!t?.path) continue;
|
|
151
|
+
const key = canonicalIterationPath(String(t.path));
|
|
152
|
+
teamIterByPath.set(key, t);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const mapped: IterationInfoType[] = teamIterations
|
|
156
|
+
.map((node: any) => {
|
|
157
|
+
const nodePathRaw = String(node.path ?? node.name ?? "");
|
|
158
|
+
const lookupKey = canonicalIterationPath(nodePathRaw);
|
|
159
|
+
const teamIteration = teamIterByPath.get(lookupKey);
|
|
160
|
+
const startRaw = teamIteration?.attributes?.startDate ?? node.attributes?.startDate;
|
|
161
|
+
const finishRaw = teamIteration?.attributes?.finishDate ?? node.attributes?.finishDate;
|
|
162
|
+
const finalPath = normalizeIterationPath(String(teamIteration?.path ?? node.path ?? nodePathRaw));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id: node.id,
|
|
166
|
+
timeFrame: teamIteration?.attributes?.timeFrame,
|
|
167
|
+
name: node.name,
|
|
168
|
+
path: finalPath,
|
|
169
|
+
startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
|
|
170
|
+
finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
|
|
171
|
+
projectId,
|
|
172
|
+
teamId,
|
|
173
|
+
} as IterationInfoType;
|
|
174
|
+
})
|
|
175
|
+
.filter((iter) => {
|
|
176
|
+
if (!iter.startDate || !iter.finishDate) return false;
|
|
177
|
+
const s = Date.parse(iter.startDate);
|
|
178
|
+
const f = Date.parse(iter.finishDate);
|
|
179
|
+
return Number.isFinite(s) && Number.isFinite(f);
|
|
180
|
+
});
|
|
181
|
+
return mapped;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Canonicalizes an iteration path for reliable comparisons and deduplication.
|
|
186
|
+
*
|
|
187
|
+
* Normalizes slashes, removes leading separators, and strips the "\Iteration\" segment.
|
|
188
|
+
*
|
|
189
|
+
* @param path - Raw iteration path string
|
|
190
|
+
* @returns Canonicalized iteration path string
|
|
191
|
+
*/
|
|
192
|
+
export function canonicalIterationPath(path: string): string {
|
|
193
|
+
let p = (path ?? "").trim().toLowerCase();
|
|
194
|
+
|
|
195
|
+
// normalize separators
|
|
196
|
+
p = p.replace(/\//g, "\\");
|
|
197
|
+
|
|
198
|
+
// collapse multiple backslashes
|
|
199
|
+
p = p.replace(/\\\\+/g, "\\");
|
|
200
|
+
|
|
201
|
+
// remove ALL leading backslashes
|
|
202
|
+
p = p.replace(/^\\+/, "");
|
|
203
|
+
|
|
204
|
+
// remove ALL trailing backslashes
|
|
205
|
+
p = p.replace(/\\+$/, "");
|
|
206
|
+
|
|
207
|
+
// remove the "\iteration\" segment if present (case-insensitive because we lowercased)
|
|
208
|
+
p = p.replace(/\\iteration\\/, "\\");
|
|
209
|
+
|
|
210
|
+
return p;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Compares two iteration paths for equality after canonical normalization.
|
|
215
|
+
*
|
|
216
|
+
* @param a - First iteration path
|
|
217
|
+
* @param b - Second iteration path
|
|
218
|
+
* @returns True if canonicalized paths match; otherwise false.
|
|
219
|
+
*/
|
|
220
|
+
export function iterationPathsEqual(a?: string | null, b?: string | null): boolean {
|
|
221
|
+
return canonicalIterationPath(a ?? "") === canonicalIterationPath(b ?? "");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
//#region Private Functions
|
|
225
|
+
/**
|
|
226
|
+
* Resolves a TeamContext for Work REST calls by looking up project and team names.
|
|
227
|
+
*
|
|
228
|
+
* Falls back to provided ids if names are unavailable.
|
|
229
|
+
*
|
|
230
|
+
* @param projectId - The unique identifier of the project
|
|
231
|
+
* @param teamId - The unique identifier of the team
|
|
232
|
+
* @returns A promise resolving to a TeamContext object
|
|
233
|
+
*/
|
|
234
|
+
async function resolveTeamContext(projectId: string, teamId: string): Promise<TeamContext> {
|
|
235
|
+
const core = getClient(CoreRestClient);
|
|
236
|
+
|
|
237
|
+
const project = await core.getProject(projectId);
|
|
238
|
+
const team = await core.getTeam(projectId, teamId);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
projectId: projectId,
|
|
242
|
+
teamId: teamId,
|
|
243
|
+
project: project && project.name ? project.name : projectId,
|
|
244
|
+
team: team && team.name ? team.name : teamId
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Normalizes an iteration path for WIQL compatibility.
|
|
250
|
+
*
|
|
251
|
+
* Collapses duplicate backslashes, removes leading separators,
|
|
252
|
+
* and strips the "\Iteration\" segment.
|
|
253
|
+
*
|
|
254
|
+
* @param path - Raw iteration path string
|
|
255
|
+
* @returns Normalized iteration path string
|
|
256
|
+
*/
|
|
257
|
+
function normalizeIterationPath(path: string): string {
|
|
258
|
+
let p = (path ?? "").trim();
|
|
259
|
+
|
|
260
|
+
// Convert double slashes to single
|
|
261
|
+
p = p.replace(/\\\\+/g, "\\");
|
|
262
|
+
|
|
263
|
+
// Remove ALL leading backslashes
|
|
264
|
+
p = p.replace(/^\\+/, "");
|
|
265
|
+
|
|
266
|
+
// Some sources include "\Iteration\" explicitly; many queries expect it without that segment.
|
|
267
|
+
// If your WIQL uses [System.IterationPath] = 'Project\...\Sprint', it should NOT contain "\Iteration\".
|
|
268
|
+
p = p.replace(/\\Iteration\\/, "\\");
|
|
269
|
+
|
|
270
|
+
return p;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Deduplicates iterations by canonical path and sorts them by start date (ascending).
|
|
275
|
+
*
|
|
276
|
+
* Uses iteration name as a secondary tie-breaker when dates match.
|
|
277
|
+
*
|
|
278
|
+
* @param items - Array of IterationInfo objects
|
|
279
|
+
* @returns Deduped and sorted array of IterationInfo objects
|
|
280
|
+
*/
|
|
281
|
+
function dedupeAndSortIterations(items: IterationInfoType[]) {
|
|
282
|
+
const byKey = new Map<string, IterationInfoType>();
|
|
283
|
+
for (const it of items) {
|
|
284
|
+
const key = canonicalIterationPath(String(it.path ?? it.name ?? it.id ?? ""));
|
|
285
|
+
if (!byKey.has(key)) byKey.set(key, it);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const unique = Array.from(byKey.values());
|
|
289
|
+
unique.sort((a, b) => {
|
|
290
|
+
const aTs = Date.parse(a.startDate ?? "");
|
|
291
|
+
const bTs = Date.parse(b.startDate ?? "");
|
|
292
|
+
if (aTs !== bTs) return aTs - bTs;
|
|
293
|
+
return (a.name ?? "").localeCompare(b.name ?? "");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return unique;
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { fetchIterations } from "features/iterations/api/iterations";
|
|
2
|
+
import { IterationInfoType } from "features/iterations/types/IterationInfoType";
|
|
3
|
+
import { SelectedProjectType } from "features/teams/types/SelectedProjectType";
|
|
4
|
+
import { toIsoDate } from "src/app/utils/date";
|
|
5
|
+
import { extractProjectFromPath } from "src/app/utils/global";
|
|
6
|
+
|
|
7
|
+
export const IterationService = {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @description Filters iterations for the current year (Azure DevOps-like),
|
|
11
|
+
* scoped to the provided project-team pairs.
|
|
12
|
+
*
|
|
13
|
+
* It assumes iteration paths contain the year as a folder segment, e.g.:
|
|
14
|
+
* "MustafaTeamPath/2026/Sprint 1"
|
|
15
|
+
*
|
|
16
|
+
* Rules:
|
|
17
|
+
* - Only iterations that belong to selected project-team pairs
|
|
18
|
+
* - Only iterations whose path includes `/<currentYear>/`
|
|
19
|
+
* - Missing start/finish dates are skipped
|
|
20
|
+
* - Results are sorted by start date ascending
|
|
21
|
+
* - Deduplicated by id (fallback to project/team + dates)
|
|
22
|
+
*/
|
|
23
|
+
getIterationsInScope: (iterations: IterationInfoType[], projectTeamPairs: SelectedProjectType[]): IterationInfoType[] => {
|
|
24
|
+
|
|
25
|
+
const year = new Date().getFullYear();
|
|
26
|
+
const yearSegment = `\\${year}\\`;
|
|
27
|
+
|
|
28
|
+
const inCurrentYearFolder = (iter: IterationInfoType) => typeof iter.path === "string" && iter.path.includes(yearSegment);
|
|
29
|
+
const belongsToSelectedPair = (iter: IterationInfoType) => projectTeamPairs.some(p => p.projectId === iter.projectId && p.teamId === iter.teamId);
|
|
30
|
+
|
|
31
|
+
const filtered = iterations.filter(iter => {
|
|
32
|
+
if (!iter.startDate || !iter.finishDate) return false;
|
|
33
|
+
if (!belongsToSelectedPair(iter)) return false;
|
|
34
|
+
if (!inCurrentYearFolder(iter)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
filtered.sort((a, b) => Date.parse(a.startDate!) - Date.parse(b.startDate!));
|
|
39
|
+
|
|
40
|
+
// Deduplicate
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const deduped: IterationInfoType[] = [];
|
|
43
|
+
for (const it of filtered) {
|
|
44
|
+
const key = (it.id ? String(it.id) : `${it.projectId}:${it.teamId}:${it.startDate}:${it.finishDate}`) + `@${it.projectId}:${it.teamId}`;
|
|
45
|
+
if (!seen.has(key)) {
|
|
46
|
+
seen.add(key);
|
|
47
|
+
deduped.push(it);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return deduped;
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gets allowed iteration paths for the selected project-team pairs based on the chosen iteration group's dates.
|
|
57
|
+
*
|
|
58
|
+
* Uses fetchIterations to retrieve iterations for each project-team pair, then filters them to find those
|
|
59
|
+
* that match the start and finish dates of the chosen iteration group.
|
|
60
|
+
*
|
|
61
|
+
* @param selectedProjectPairs - Array of selected project and team ID pairs
|
|
62
|
+
* @param chosenIterationGroup - The iteration group with startDate and finishDate to match against
|
|
63
|
+
* @returns A promise resolving to an array of allowed iteration paths
|
|
64
|
+
*/
|
|
65
|
+
getAllowedIterationPathsForSelection: async (selectedProjectPairs: SelectedProjectType[], chosenIterationGroup: { startDate: string; finishDate: string }): Promise<string[]> => {
|
|
66
|
+
if (!Array.isArray(selectedProjectPairs) || selectedProjectPairs.length === 0) return [];
|
|
67
|
+
|
|
68
|
+
const { startDate: chosenStartRaw, finishDate: chosenFinishRaw } = chosenIterationGroup || {};
|
|
69
|
+
|
|
70
|
+
const results = await Promise.all(
|
|
71
|
+
selectedProjectPairs.map(p => fetchIterations(p.projectId, p.teamId))
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (!chosenStartRaw || !chosenFinishRaw) return [];
|
|
75
|
+
const chosenStart = Date.parse(chosenStartRaw);
|
|
76
|
+
const chosenFinish = Date.parse(chosenFinishRaw);
|
|
77
|
+
const allIterations = results.flat().filter(Boolean);
|
|
78
|
+
|
|
79
|
+
// pick only iterations whose start/finish match chosenIterationGroup
|
|
80
|
+
const allowed = allIterations
|
|
81
|
+
.filter(iter => iter && iter.startDate && iter.finishDate)
|
|
82
|
+
.filter(iter => {
|
|
83
|
+
const s = Date.parse(iter.startDate!);
|
|
84
|
+
const f = Date.parse(iter.finishDate!);
|
|
85
|
+
|
|
86
|
+
// skip invalid iteration dates
|
|
87
|
+
if (isNaN(s) || isNaN(f)) return false;
|
|
88
|
+
|
|
89
|
+
return s === chosenStart && f === chosenFinish;
|
|
90
|
+
})
|
|
91
|
+
.map(iter => iter.path);
|
|
92
|
+
|
|
93
|
+
return Array.from(new Set(allowed));
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Filters a list of iterations to include only future iterations
|
|
98
|
+
* starting from the **first day of the next month** up to the **end of the current year**.
|
|
99
|
+
*
|
|
100
|
+
* The lower bound is calculated as **00:00 (local time)** on the **1st day of the next month**.
|
|
101
|
+
* The upper bound is calculated as **23:59:59.999 (local time)** on **December 31st of the current year**.
|
|
102
|
+
*
|
|
103
|
+
* An iteration is included if its **startDate** falls within this time window.
|
|
104
|
+
*
|
|
105
|
+
* Invalid iterations are excluded:
|
|
106
|
+
* - Missing startDate or finishDate
|
|
107
|
+
* - Unparseable dates (Date.parse returns NaN)
|
|
108
|
+
* - The current iteration (if provided), matched by id
|
|
109
|
+
*
|
|
110
|
+
* @param iterations - Array of iterations to filter
|
|
111
|
+
* @param currentIteration - The currently active iteration to exclude from the results (optional)
|
|
112
|
+
* @returns A filtered array containing future iterations from next month until the end of the year
|
|
113
|
+
*/
|
|
114
|
+
filterFutureIterations: (iterations: IterationInfoType[], currentIteration: IterationInfoType | undefined | null) => {
|
|
115
|
+
const now = new Date();
|
|
116
|
+
|
|
117
|
+
// Start at 00:00 on the 1st day of NEXT month (local time)
|
|
118
|
+
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
|
119
|
+
|
|
120
|
+
// End at 23:59:59.999 on Dec 31 of THIS year (local time)
|
|
121
|
+
const endOfYear = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).getTime();
|
|
122
|
+
|
|
123
|
+
return iterations.filter(({ id, startDate, finishDate }) => {
|
|
124
|
+
if (!startDate || !finishDate) return false;
|
|
125
|
+
|
|
126
|
+
const start = Date.parse(startDate);
|
|
127
|
+
const end = Date.parse(finishDate);
|
|
128
|
+
if (Number.isNaN(start) || Number.isNaN(end)) return false;
|
|
129
|
+
|
|
130
|
+
// exclude current iteration (if provided)
|
|
131
|
+
if (currentIteration?.id != null && String(currentIteration.id) === String(id)) return false;
|
|
132
|
+
|
|
133
|
+
// include iterations that START between next month start and end of year
|
|
134
|
+
return start >= startOfNextMonth && start <= endOfYear;
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Groups iterations by their start and end dates.
|
|
140
|
+
* Iterations with the same start and end dates are combined into a single entry,
|
|
141
|
+
* with their paths and included projects aggregated.
|
|
142
|
+
*
|
|
143
|
+
* @param iterations Array of IterationInfoType objects
|
|
144
|
+
* @returns Array of grouped IterationInfoType objects
|
|
145
|
+
*/
|
|
146
|
+
groupIterationsByStartEnd: (iterations: IterationInfoType[]): IterationInfoType[] => {
|
|
147
|
+
const map = new Map<string, IterationInfoType>();
|
|
148
|
+
|
|
149
|
+
for (const iter of iterations) {
|
|
150
|
+
const projectFromPath = extractProjectFromPath(iter.path);
|
|
151
|
+
|
|
152
|
+
if (iter.startDate && iter.finishDate) {
|
|
153
|
+
const startDate = toIsoDate(iter.startDate);
|
|
154
|
+
const finishDate = toIsoDate(iter.finishDate);
|
|
155
|
+
const key = `DATED__${startDate}__${finishDate}`;
|
|
156
|
+
|
|
157
|
+
const existing = map.get(key);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
map.set(key, {
|
|
160
|
+
...iter,
|
|
161
|
+
startDate,
|
|
162
|
+
finishDate,
|
|
163
|
+
paths: [iter.path],
|
|
164
|
+
projectsIncludedInIteration: [projectFromPath],
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
existing.paths = Array.from(new Set([...(existing.paths ?? []), iter.path]));
|
|
168
|
+
existing.projectsIncludedInIteration = Array.from(
|
|
169
|
+
new Set([...(existing.projectsIncludedInIteration ?? []), projectFromPath])
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const key = `UNDATED__${iter.path}`;
|
|
177
|
+
|
|
178
|
+
const existing = map.get(key);
|
|
179
|
+
if (!existing) {
|
|
180
|
+
map.set(key, {
|
|
181
|
+
...iter,
|
|
182
|
+
paths: [iter.path],
|
|
183
|
+
projectsIncludedInIteration: [projectFromPath],
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
existing.paths = Array.from(new Set([...(existing.paths ?? []), iter.path]));
|
|
187
|
+
existing.projectsIncludedInIteration = Array.from(
|
|
188
|
+
new Set([...(existing.projectsIncludedInIteration ?? []), projectFromPath])
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Array.from(map.values()).sort((a, b) => {
|
|
194
|
+
|
|
195
|
+
const aHas = a.startDate && a.finishDate;
|
|
196
|
+
const bHas = b.startDate && b.finishDate;
|
|
197
|
+
if (aHas && !bHas) return -1;
|
|
198
|
+
if (!aHas && bHas) return 1;
|
|
199
|
+
|
|
200
|
+
if (aHas && bHas) return String(a.startDate).localeCompare(String(b.startDate));
|
|
201
|
+
|
|
202
|
+
return String(a.path).localeCompare(String(b.path));
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Gets iteration paths from an array of iterations.
|
|
208
|
+
*
|
|
209
|
+
* @param iterations Array of IterationInfoType objects
|
|
210
|
+
* @returns Array of iteration paths as strings
|
|
211
|
+
*/
|
|
212
|
+
getIterationPaths: (iterations: IterationInfoType[]): string[] => {
|
|
213
|
+
return iterations.map(iter => iter.path);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type IterationInfoType = {
|
|
2
|
+
id: string;
|
|
3
|
+
iterationId?: number;
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
startDate?: string;
|
|
7
|
+
finishDate?: string;
|
|
8
|
+
projectId: string;
|
|
9
|
+
teamId: string;
|
|
10
|
+
paths?: string[];
|
|
11
|
+
timeFrame?: number;
|
|
12
|
+
level1?: string;
|
|
13
|
+
level2?: string;
|
|
14
|
+
projectsIncludedInIteration?: string[];
|
|
15
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { IterationInfoType } from "features/iterations/types/IterationInfoType";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { formatIteration } from "../iteration.js";
|
|
4
|
+
|
|
5
|
+
describe("Utils: Iteration Helper Tests: ", () => {
|
|
6
|
+
|
|
7
|
+
//#region formatIteration
|
|
8
|
+
it("formatIteration: returns formatted iteration in specified format if there is any provided iteration", () => {
|
|
9
|
+
|
|
10
|
+
const iteration: IterationInfoType = {
|
|
11
|
+
id: "1",
|
|
12
|
+
name: "Iteration Name",
|
|
13
|
+
path: "Iteration Path",
|
|
14
|
+
startDate: "2026-01-15T00:00:00.000Z",
|
|
15
|
+
finishDate: "2026-01-31T23:59:00.000Z",
|
|
16
|
+
projectId: "ProjectId",
|
|
17
|
+
teamId: "TeamId"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
expect(formatIteration(iteration)).toBe("2026 Jan (15-31)");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("formatIteration: returns empty string if startDate is invalid", () => {
|
|
24
|
+
|
|
25
|
+
const iteration: IterationInfoType = {
|
|
26
|
+
id: "1",
|
|
27
|
+
name: "Iteration Name",
|
|
28
|
+
path: "Iteration Path",
|
|
29
|
+
startDate: "2026-01-15",
|
|
30
|
+
finishDate: "not-a-date",
|
|
31
|
+
projectId: "ProjectId",
|
|
32
|
+
teamId: "TeamId",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(formatIteration(iteration as any)).toBe("");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
})
|