zephyr-enterprise-tools 1.2.8 → 1.3.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/cli.js +63 -0
- package/mcp-server.js +63 -0
- package/package.json +1 -1
- package/zephyr-enterprise-tools.d.ts +124 -0
- package/zephyr-enterprise-tools.js +484 -48
package/cli.js
CHANGED
|
@@ -42,6 +42,7 @@ function parseArgs() {
|
|
|
42
42
|
query: '',
|
|
43
43
|
days: 30,
|
|
44
44
|
limit: 50,
|
|
45
|
+
cycleId: null,
|
|
45
46
|
};
|
|
46
47
|
|
|
47
48
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -76,6 +77,10 @@ function parseArgs() {
|
|
|
76
77
|
case '--limit':
|
|
77
78
|
options.limit = parseInt(args[++i], 10) || 50;
|
|
78
79
|
break;
|
|
80
|
+
case '-c':
|
|
81
|
+
case '--cycle':
|
|
82
|
+
options.cycleId = Number(args[++i]);
|
|
83
|
+
break;
|
|
79
84
|
case '--json':
|
|
80
85
|
options.format = 'json';
|
|
81
86
|
break;
|
|
@@ -117,6 +122,7 @@ OPTIONS:
|
|
|
117
122
|
-q, --query <text> Search query (for search-tests)
|
|
118
123
|
-d, --days <n> Number of days for trends/activity (default: 30)
|
|
119
124
|
-l, --limit <n> Max results to return (default: 50)
|
|
125
|
+
-c, --cycle <id> Cycle ID (for get-cycle)
|
|
120
126
|
--json Output as JSON
|
|
121
127
|
--table Output as table (default)
|
|
122
128
|
-h, --help Show this help
|
|
@@ -140,6 +146,8 @@ AVAILABLE TOOLS:
|
|
|
140
146
|
│ test-trends Test execution trends over time │
|
|
141
147
|
│ search-tests Search test cases by query │
|
|
142
148
|
│ user-activity User activity and productivity metrics │
|
|
149
|
+
│ list-cycles List all cycles for a release with phase details │
|
|
150
|
+
│ get-cycle Get full details for one cycle (-c <cycleId>) │
|
|
143
151
|
└─────────────────────────────────────────────────────────────────────────────┘
|
|
144
152
|
|
|
145
153
|
ENVIRONMENT VARIABLES:
|
|
@@ -165,6 +173,12 @@ EXAMPLES:
|
|
|
165
173
|
# Get user activity report
|
|
166
174
|
zephyr-tools -p 364 -r 4312 -t user-activity
|
|
167
175
|
|
|
176
|
+
# List all cycles and phase details for a release
|
|
177
|
+
zephyr-tools -p 364 -r 4312 -t list-cycles
|
|
178
|
+
|
|
179
|
+
# Get a single cycle detail
|
|
180
|
+
zephyr-tools -p 364 -r 4312 -t get-cycle -c 98765
|
|
181
|
+
|
|
168
182
|
QUALITY GATE THRESHOLDS:
|
|
169
183
|
Requirement Coverage: ≥70% = GO
|
|
170
184
|
Test Plan Analysis: <80% = NO GO, 80-90% = CONDITIONAL, ≥90% = GO
|
|
@@ -412,6 +426,16 @@ function formatGenericResult(result) {
|
|
|
412
426
|
}
|
|
413
427
|
console.log(' └────────────┴───────┴────────┴────────┴─────────┘');
|
|
414
428
|
}
|
|
429
|
+
|
|
430
|
+
// Cycles list/detail
|
|
431
|
+
if (result.cycles && result.cycles.length > 0) {
|
|
432
|
+
console.log(`\n🔁 CYCLES (${result.total || result.cycles.length}):`);
|
|
433
|
+
for (const cycle of result.cycles) {
|
|
434
|
+
printCycleDetail(cycle);
|
|
435
|
+
}
|
|
436
|
+
} else if (result.tool === 'Get Cycle' || result.cycleId) {
|
|
437
|
+
printCycleDetail(result);
|
|
438
|
+
}
|
|
415
439
|
|
|
416
440
|
// Recommendations
|
|
417
441
|
if (result.recommendations) {
|
|
@@ -449,6 +473,35 @@ function formatKey(key) {
|
|
|
449
473
|
.trim();
|
|
450
474
|
}
|
|
451
475
|
|
|
476
|
+
function printCycleDetail(cycle) {
|
|
477
|
+
console.log(` • ${cycle.name || 'Unnamed Cycle'} [ID: ${cycle.id || cycle.cycleId}]`);
|
|
478
|
+
if (cycle.environment || cycle.build || cycle.status) {
|
|
479
|
+
console.log(` Environment: ${cycle.environment || 'N/A'} | Build: ${cycle.build || 'N/A'} | Status: ${cycle.status || 'N/A'}`);
|
|
480
|
+
}
|
|
481
|
+
if (cycle.startDate || cycle.endDate) {
|
|
482
|
+
console.log(` Dates: ${cycle.startDate || 'N/A'} → ${cycle.endDate || 'N/A'}`);
|
|
483
|
+
}
|
|
484
|
+
if (cycle.executionStatusCounts?.breakdown?.length) {
|
|
485
|
+
const breakdown = cycle.executionStatusCounts.breakdown
|
|
486
|
+
.map(item => `${item.label}: ${item.count}`)
|
|
487
|
+
.join(', ');
|
|
488
|
+
console.log(` Execution Status: total ${cycle.executionStatusCounts.total}; ${breakdown}`);
|
|
489
|
+
}
|
|
490
|
+
if (cycle.phases?.length) {
|
|
491
|
+
console.log(` Phases (${cycle.phases.length}):`);
|
|
492
|
+
for (const phase of cycle.phases) {
|
|
493
|
+
const dates = phase.startDate || phase.endDate ? ` (${phase.startDate || 'N/A'} → ${phase.endDate || 'N/A'})` : '';
|
|
494
|
+
console.log(` - ${phase.name || 'Unnamed Phase'} [ID: ${phase.id}]${dates}`);
|
|
495
|
+
if (phase.executionStatusCounts?.breakdown?.length) {
|
|
496
|
+
const breakdown = phase.executionStatusCounts.breakdown
|
|
497
|
+
.map(item => `${item.label}: ${item.count}`)
|
|
498
|
+
.join(', ');
|
|
499
|
+
console.log(` Execution Status: total ${phase.executionStatusCounts.total}; ${breakdown}`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
452
505
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
453
506
|
|
|
454
507
|
async function main() {
|
|
@@ -515,6 +568,16 @@ async function main() {
|
|
|
515
568
|
case 'user-activity':
|
|
516
569
|
result = await tools.getUserActivity(projectId, releaseId, { days });
|
|
517
570
|
break;
|
|
571
|
+
case 'list-cycles':
|
|
572
|
+
result = await tools.listCycles(releaseId);
|
|
573
|
+
break;
|
|
574
|
+
case 'get-cycle':
|
|
575
|
+
if (!options.cycleId) {
|
|
576
|
+
console.error('Error: --cycle is required for get-cycle.');
|
|
577
|
+
process.exit(1);
|
|
578
|
+
}
|
|
579
|
+
result = await tools.getCycle(options.cycleId);
|
|
580
|
+
break;
|
|
518
581
|
|
|
519
582
|
default:
|
|
520
583
|
console.error(`Unknown tool: ${tool}`);
|
package/mcp-server.js
CHANGED
|
@@ -31,6 +31,20 @@ const TOOLS = [
|
|
|
31
31
|
required: ['projectId', 'releaseId'],
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
name: 'compare_releases',
|
|
36
|
+
description: 'Run release readiness for two releases in the same project and return a side-by-side diff across all 4 gates, including which gate statuses changed and their metric deltas.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
projectId: { type: 'number', description: 'Zephyr project ID' },
|
|
41
|
+
releaseId1: { type: 'number', description: 'First (baseline) Zephyr release ID' },
|
|
42
|
+
releaseId2: { type: 'number', description: 'Second (comparison) Zephyr release ID' },
|
|
43
|
+
query: { type: 'string', description: 'Optional Zephyr ZQL expression for Test Plan Analysis, e.g. priority = "P1"' },
|
|
44
|
+
},
|
|
45
|
+
required: ['projectId', 'releaseId1', 'releaseId2'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
34
48
|
{
|
|
35
49
|
name: 'requirement_coverage',
|
|
36
50
|
description: 'Check if requirements are covered by test cases. Threshold: ≥70% = GO.',
|
|
@@ -220,6 +234,39 @@ const TOOLS = [
|
|
|
220
234
|
required: ['projectId'],
|
|
221
235
|
},
|
|
222
236
|
},
|
|
237
|
+
{
|
|
238
|
+
name: 'list_users',
|
|
239
|
+
description: 'List all users assigned to a project, with name, email, role, and account status.',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
projectId: { type: 'number', description: 'Zephyr project ID' },
|
|
244
|
+
},
|
|
245
|
+
required: ['projectId'],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'list_cycles',
|
|
250
|
+
description: 'List all test cycles for a release, including their phases and date ranges.',
|
|
251
|
+
inputSchema: {
|
|
252
|
+
type: 'object',
|
|
253
|
+
properties: {
|
|
254
|
+
releaseId: { type: 'number', description: 'Zephyr release ID' },
|
|
255
|
+
},
|
|
256
|
+
required: ['releaseId'],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'get_cycle',
|
|
261
|
+
description: 'Get full detail for a single test cycle, including its phases.',
|
|
262
|
+
inputSchema: {
|
|
263
|
+
type: 'object',
|
|
264
|
+
properties: {
|
|
265
|
+
cycleId: { type: 'number', description: 'Zephyr cycle ID' },
|
|
266
|
+
},
|
|
267
|
+
required: ['cycleId'],
|
|
268
|
+
},
|
|
269
|
+
},
|
|
223
270
|
];
|
|
224
271
|
|
|
225
272
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
@@ -269,6 +316,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
269
316
|
result = await tools.runAllGates(projectId, releaseId, { query: args.query });
|
|
270
317
|
break;
|
|
271
318
|
|
|
319
|
+
case 'compare_releases':
|
|
320
|
+
result = await tools.compareReleases(projectId, args.releaseId1, args.releaseId2, { query: args.query });
|
|
321
|
+
break;
|
|
322
|
+
|
|
272
323
|
case 'requirement_coverage':
|
|
273
324
|
result = await tools.requirementCoverageGate(projectId, releaseId);
|
|
274
325
|
break;
|
|
@@ -363,6 +414,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
363
414
|
})),
|
|
364
415
|
};
|
|
365
416
|
break;
|
|
417
|
+
|
|
418
|
+
case 'list_users':
|
|
419
|
+
result = await tools.listUsers(projectId);
|
|
420
|
+
break;
|
|
421
|
+
|
|
422
|
+
case 'list_cycles':
|
|
423
|
+
result = await tools.listCycles(releaseId);
|
|
424
|
+
break;
|
|
425
|
+
|
|
426
|
+
case 'get_cycle':
|
|
427
|
+
result = await tools.getCycle(args.cycleId);
|
|
428
|
+
break;
|
|
366
429
|
|
|
367
430
|
default:
|
|
368
431
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zephyr-enterprise-tools",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Comprehensive Zephyr Enterprise Tools - Release Readiness, Project Health, Test Analytics & More",
|
|
5
5
|
"main": "zephyr-enterprise-tools.js",
|
|
6
6
|
"types": "zephyr-enterprise-tools.d.ts",
|
|
@@ -88,6 +88,30 @@ export interface ReleaseReadinessResult {
|
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export interface GateComparison {
|
|
92
|
+
metric: string;
|
|
93
|
+
releaseA: { status: GateStatus; value: number };
|
|
94
|
+
releaseB: { status: GateStatus; value: number };
|
|
95
|
+
delta: number;
|
|
96
|
+
statusChanged: boolean;
|
|
97
|
+
trend: 'improved' | 'regressed' | 'unchanged';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface CompareReleasesResult {
|
|
101
|
+
projectId: number;
|
|
102
|
+
timestamp: string;
|
|
103
|
+
query?: string;
|
|
104
|
+
releaseA: { releaseId: number; projectName: string; releaseName: string; generatedAt: string; fileName: string; overallStatus: GateStatus };
|
|
105
|
+
releaseB: { releaseId: number; projectName: string; releaseName: string; generatedAt: string; fileName: string; overallStatus: GateStatus };
|
|
106
|
+
overallStatusChanged: boolean;
|
|
107
|
+
gates: {
|
|
108
|
+
requirementCoverage: GateComparison;
|
|
109
|
+
testPlanAnalysis: GateComparison;
|
|
110
|
+
testExecution: GateComparison;
|
|
111
|
+
defectQuality: GateComparison;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
91
115
|
export interface ProjectHealthResult {
|
|
92
116
|
tool: string;
|
|
93
117
|
projectId: number;
|
|
@@ -164,6 +188,14 @@ export interface UserActivityOptions {
|
|
|
164
188
|
days?: number;
|
|
165
189
|
}
|
|
166
190
|
|
|
191
|
+
export interface UserActivityTrendSummary {
|
|
192
|
+
mostActiveUser: { userId: number; name: string; executed: number } | null;
|
|
193
|
+
leastActiveUser: { userId: number; name: string; executed: number } | null;
|
|
194
|
+
avgCompletionRate: number;
|
|
195
|
+
teamVelocityTrend: 'increasing' | 'decreasing' | 'steady' | 'insufficient data';
|
|
196
|
+
velocity: { firstHalfAvgPerDay: number; secondHalfAvgPerDay: number; changePct: number } | null;
|
|
197
|
+
}
|
|
198
|
+
|
|
167
199
|
export interface UserActivityResult {
|
|
168
200
|
tool: string;
|
|
169
201
|
projectId: number;
|
|
@@ -171,6 +203,7 @@ export interface UserActivityResult {
|
|
|
171
203
|
timestamp: string;
|
|
172
204
|
period: { days: number };
|
|
173
205
|
teamSummary: Record<string, unknown>;
|
|
206
|
+
trendSummary: UserActivityTrendSummary;
|
|
174
207
|
assignedTo: unknown[];
|
|
175
208
|
executedBy: unknown[];
|
|
176
209
|
topExecutors: unknown[];
|
|
@@ -184,6 +217,93 @@ export interface TrendsOptions {
|
|
|
184
217
|
days?: number;
|
|
185
218
|
}
|
|
186
219
|
|
|
220
|
+
export interface ListUsersOptions {
|
|
221
|
+
pageSize?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ZephyrUser {
|
|
225
|
+
id: number;
|
|
226
|
+
fullName: string;
|
|
227
|
+
userName: string;
|
|
228
|
+
email: string;
|
|
229
|
+
title?: string;
|
|
230
|
+
location?: string;
|
|
231
|
+
accountEnabled: boolean;
|
|
232
|
+
roles: number[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface ListUsersResult {
|
|
236
|
+
tool: string;
|
|
237
|
+
projectId: number;
|
|
238
|
+
timestamp: string;
|
|
239
|
+
total: number;
|
|
240
|
+
returned: number;
|
|
241
|
+
note?: string;
|
|
242
|
+
users: ZephyrUser[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface ExecutionStatusBreakdown {
|
|
246
|
+
statusCode: number;
|
|
247
|
+
label: string;
|
|
248
|
+
count: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ExecutionStatusCounts {
|
|
252
|
+
total: number;
|
|
253
|
+
breakdown: ExecutionStatusBreakdown[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface CyclePhase {
|
|
257
|
+
id: number;
|
|
258
|
+
name: string;
|
|
259
|
+
startDate: string;
|
|
260
|
+
endDate: string;
|
|
261
|
+
executionStatusCounts: ExecutionStatusCounts | null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ZephyrCycle {
|
|
265
|
+
id: number;
|
|
266
|
+
name: string;
|
|
267
|
+
environment?: string;
|
|
268
|
+
build?: string;
|
|
269
|
+
startDate: string;
|
|
270
|
+
endDate: string;
|
|
271
|
+
status: number;
|
|
272
|
+
executionStatusCounts: ExecutionStatusCounts | null;
|
|
273
|
+
phases: CyclePhase[];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface ListCyclesResult {
|
|
277
|
+
tool: string;
|
|
278
|
+
releaseId: number;
|
|
279
|
+
timestamp: string;
|
|
280
|
+
total: number;
|
|
281
|
+
cycles: ZephyrCycle[];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export interface CyclePhaseDetail extends CyclePhase {
|
|
285
|
+
freeForm?: boolean;
|
|
286
|
+
resetExecution?: boolean;
|
|
287
|
+
hasChild?: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface GetCycleResult {
|
|
291
|
+
tool: string;
|
|
292
|
+
cycleId: number;
|
|
293
|
+
timestamp: string;
|
|
294
|
+
id: number;
|
|
295
|
+
name: string;
|
|
296
|
+
environment?: string;
|
|
297
|
+
build?: string;
|
|
298
|
+
startDate: string;
|
|
299
|
+
endDate: string;
|
|
300
|
+
status: number;
|
|
301
|
+
releaseId: number;
|
|
302
|
+
hasChild?: boolean;
|
|
303
|
+
executionStatusCounts: ExecutionStatusCounts | null;
|
|
304
|
+
phases: CyclePhaseDetail[];
|
|
305
|
+
}
|
|
306
|
+
|
|
187
307
|
export declare class QualityGates {
|
|
188
308
|
constructor(config: ZephyrConfig);
|
|
189
309
|
|
|
@@ -193,6 +313,7 @@ export declare class QualityGates {
|
|
|
193
313
|
testExecutionGate(projectId: number, releaseId: number): Promise<TestExecutionResult>;
|
|
194
314
|
defectQualityGate(projectId: number, releaseId: number): Promise<DefectQualityResult>;
|
|
195
315
|
runAllGates(projectId: number, releaseId: number, options?: TestPlanOptions): Promise<ReleaseReadinessResult>;
|
|
316
|
+
compareReleases(projectId: number, releaseId1: number, releaseId2: number, options?: TestPlanOptions): Promise<CompareReleasesResult>;
|
|
196
317
|
|
|
197
318
|
// Analytics & Insights
|
|
198
319
|
getProjectHealth(projectId: number, releaseId: number): Promise<ProjectHealthResult>;
|
|
@@ -202,6 +323,9 @@ export declare class QualityGates {
|
|
|
202
323
|
getTestCaseTrends(projectId: number, releaseId: number, options?: TrendsOptions): Promise<TestTrendsResult>;
|
|
203
324
|
searchTestCases(projectId: number, releaseId: number, options?: SearchTestCasesOptions): Promise<SearchTestCasesResult>;
|
|
204
325
|
getUserActivity(projectId: number, releaseId: number, options?: UserActivityOptions): Promise<UserActivityResult>;
|
|
326
|
+
listUsers(projectId: number, options?: ListUsersOptions): Promise<ListUsersResult>;
|
|
327
|
+
listCycles(releaseId: number): Promise<ListCyclesResult>;
|
|
328
|
+
getCycle(cycleId: number): Promise<GetCycleResult>;
|
|
205
329
|
}
|
|
206
330
|
|
|
207
331
|
export declare const THRESHOLDS: ThresholdConfig;
|
|
@@ -54,12 +54,32 @@ export const BLOCKER_PRIORITIES = ['blocker', 'critical', 'p1', '1', 'highest'];
|
|
|
54
54
|
export const HIGH_RISK_PRIORITIES = ['high', 'medium', 'p2', 'p3', '2', '3'];
|
|
55
55
|
export const LOW_RISK_PRIORITIES = ['low', 'trivial', 'p4', 'p5', '4', '5', 'lowest', 'minor'];
|
|
56
56
|
|
|
57
|
+
const TEST_RESULT_STATUS_LOV_NAME = "testresult.testresultStatus.LOV";
|
|
58
|
+
const DEFAULT_TEST_RESULT_STATUS_IDS = {
|
|
59
|
+
unexecuted: new Set([0]),
|
|
60
|
+
passed: new Set([1]),
|
|
61
|
+
failed: new Set([2]),
|
|
62
|
+
wip: new Set([3]),
|
|
63
|
+
blocked: new Set([4]),
|
|
64
|
+
notApplicable: new Set([5]),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const TEST_RESULT_STATUS_MATCHERS = {
|
|
68
|
+
unexecuted: [/\bunexecuted\b/, /\bnot\s+executed\b/, /\bnot\s+run\b/, /\bno\s+run\b/],
|
|
69
|
+
passed: [/\bpass(?:ed)?\b/],
|
|
70
|
+
failed: [/\bfail(?:ed)?\b/],
|
|
71
|
+
wip: [/\bwip\b/, /\bwork\s+in\s+progress\b/],
|
|
72
|
+
blocked: [/\bblocked\b/],
|
|
73
|
+
notApplicable: [/\bnot\s+applicable\b/, /\bn\/?a\b/],
|
|
74
|
+
};
|
|
75
|
+
|
|
57
76
|
// ─── Quality Gates Class ──────────────────────────────────────────────────────
|
|
58
77
|
|
|
59
78
|
export class QualityGates {
|
|
60
79
|
constructor(config) {
|
|
61
80
|
this.baseUrl = (config.baseUrl || process.env.ZEPHYR_BASE_URL || "").replace(/\/$/, "");
|
|
62
81
|
this.token = config.token || process.env.ZEPHYR_TOKEN || "";
|
|
82
|
+
this.testResultStatusMapPromise = null;
|
|
63
83
|
|
|
64
84
|
if (!this.baseUrl) {
|
|
65
85
|
throw new Error("ZEPHYR_BASE_URL is required");
|
|
@@ -127,6 +147,139 @@ export class QualityGates {
|
|
|
127
147
|
return res.json();
|
|
128
148
|
}
|
|
129
149
|
|
|
150
|
+
async GETv4(path, params = {}) {
|
|
151
|
+
// Build v4 URL by replacing /latest with /v4 in baseUrl
|
|
152
|
+
const v4BaseUrl = this.baseUrl.replace('/latest', '/v4');
|
|
153
|
+
const url = new URL(`${v4BaseUrl}${path}`);
|
|
154
|
+
for (const [k, v] of Object.entries(params)) {
|
|
155
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
|
|
156
|
+
}
|
|
157
|
+
const res = await fetch(url.toString(), {
|
|
158
|
+
method: "GET",
|
|
159
|
+
headers: { Accept: "application/json", "Content-Type": "application/json", ...this.authHeader() },
|
|
160
|
+
});
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
const text = await res.text().catch(() => "");
|
|
163
|
+
throw new Error(`Zephyr v4 API ${res.status}: ${text}`);
|
|
164
|
+
}
|
|
165
|
+
return res.json();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async getTestResultStatusMap() {
|
|
169
|
+
if (!this.testResultStatusMapPromise) {
|
|
170
|
+
this.testResultStatusMapPromise = this.loadTestResultStatusMap().catch(error => {
|
|
171
|
+
this.testResultStatusMapPromise = null;
|
|
172
|
+
console.warn(`Unable to load ${TEST_RESULT_STATUS_LOV_NAME}; using default execution status IDs. ${error.message}`);
|
|
173
|
+
return this.cloneDefaultTestResultStatusIds();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return this.testResultStatusMapPromise;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async loadTestResultStatusMap() {
|
|
180
|
+
const preferences = await this.GETv4("/admin/preference/all/system");
|
|
181
|
+
const preferenceList = Array.isArray(preferences)
|
|
182
|
+
? preferences
|
|
183
|
+
: preferences.results || preferences.data || preferences.preferences || Object.values(preferences || {});
|
|
184
|
+
const preference = preferenceList.find(item => item?.name === TEST_RESULT_STATUS_LOV_NAME);
|
|
185
|
+
|
|
186
|
+
if (!preference) {
|
|
187
|
+
throw new Error(`${TEST_RESULT_STATUS_LOV_NAME} was not found in system preferences`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const statusRows = this.extractTestResultStatusRows(preference.value ?? preference.lov ?? preference);
|
|
191
|
+
if (statusRows.length === 0) {
|
|
192
|
+
throw new Error(`${TEST_RESULT_STATUS_LOV_NAME} did not include any status rows`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const statusMap = this.createEmptyTestResultStatusIds();
|
|
196
|
+
|
|
197
|
+
for (const row of statusRows) {
|
|
198
|
+
const category = this.getTestResultStatusCategory(row.label);
|
|
199
|
+
if (category) statusMap[category].add(row.id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (Object.values(statusMap).every(ids => ids.size === 0)) {
|
|
203
|
+
throw new Error(`${TEST_RESULT_STATUS_LOV_NAME} did not include recognized execution statuses`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return statusMap;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
createEmptyTestResultStatusIds() {
|
|
210
|
+
return Object.fromEntries(Object.keys(DEFAULT_TEST_RESULT_STATUS_IDS).map(status => [status, new Set()]));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
cloneDefaultTestResultStatusIds() {
|
|
214
|
+
return Object.fromEntries(
|
|
215
|
+
Object.entries(DEFAULT_TEST_RESULT_STATUS_IDS).map(([status, ids]) => [status, new Set(ids)])
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
extractTestResultStatusRows(value) {
|
|
220
|
+
const parsed = typeof value === "string" ? this.parsePreferenceValue(value) : value;
|
|
221
|
+
const rows = [];
|
|
222
|
+
|
|
223
|
+
const visit = (item) => {
|
|
224
|
+
if (!item || typeof item !== "object") return;
|
|
225
|
+
if (Array.isArray(item)) {
|
|
226
|
+
item.forEach(visit);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const id = this.getStatusIdFromPreferenceItem(item);
|
|
231
|
+
const label = this.getStatusLabelFromPreferenceItem(item);
|
|
232
|
+
if (id !== null && label) rows.push({ id, label });
|
|
233
|
+
|
|
234
|
+
for (const key of ["items", "values", "options", "lov", "list", "children", "data", "results"]) {
|
|
235
|
+
if (item[key]) visit(item[key]);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
visit(parsed);
|
|
240
|
+
return rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
parsePreferenceValue(value) {
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(value);
|
|
246
|
+
} catch {
|
|
247
|
+
return value.split(/[;,\n]/).map(entry => {
|
|
248
|
+
const [id, label] = entry.split(/[:=|]/).map(part => part?.trim());
|
|
249
|
+
return { id, label };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
getStatusIdFromPreferenceItem(item) {
|
|
255
|
+
for (const key of ["id", "statusId", "statusID", "value", "listValueId", "listValueID"]) {
|
|
256
|
+
const numberValue = Number(item[key]);
|
|
257
|
+
if (Number.isInteger(numberValue)) return numberValue;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
getStatusLabelFromPreferenceItem(item) {
|
|
263
|
+
for (const key of ["label", "name", "status", "displayName", "displayValue", "text", "title"]) {
|
|
264
|
+
if (typeof item[key] === "string" && item[key].trim()) return item[key].trim();
|
|
265
|
+
}
|
|
266
|
+
return "";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getTestResultStatusCategory(label) {
|
|
270
|
+
const normalized = label.toLowerCase();
|
|
271
|
+
return Object.entries(TEST_RESULT_STATUS_MATCHERS)
|
|
272
|
+
.find(([, matchers]) => matchers.some(matcher => matcher.test(normalized)))?.[0] || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
getExecutionStatusId(exec) {
|
|
276
|
+
return Number(exec.lastTestResult?.executionStatus ?? exec.status ?? exec.executionStatus ?? 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
isExecutionStatus(exec, statusMap, category) {
|
|
280
|
+
return statusMap[category]?.has(this.getExecutionStatusId(exec)) || false;
|
|
281
|
+
}
|
|
282
|
+
|
|
130
283
|
async searchExecutionsByZql(releaseId, query) {
|
|
131
284
|
const pageSize = 50;
|
|
132
285
|
let firstResult = 0;
|
|
@@ -309,6 +462,7 @@ export class QualityGates {
|
|
|
309
462
|
// ─── Gate 3: Test Execution Gate ────────────────────────────────────────────
|
|
310
463
|
|
|
311
464
|
async testExecutionGate(projectId, releaseId) {
|
|
465
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
312
466
|
const executionData = await this.GET("/execution", {
|
|
313
467
|
releaseid: releaseId,
|
|
314
468
|
offset: 0,
|
|
@@ -337,15 +491,12 @@ export class QualityGates {
|
|
|
337
491
|
let passed = 0, failed = 0, notApplicable = 0, wip = 0, blocked = 0, notExecuted = 0;
|
|
338
492
|
|
|
339
493
|
for (const exec of executions) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
case 5: notApplicable++; break;
|
|
347
|
-
default: notExecuted++; break;
|
|
348
|
-
}
|
|
494
|
+
if (this.isExecutionStatus(exec, statusMap, "passed")) passed++;
|
|
495
|
+
else if (this.isExecutionStatus(exec, statusMap, "failed")) failed++;
|
|
496
|
+
else if (this.isExecutionStatus(exec, statusMap, "wip")) wip++;
|
|
497
|
+
else if (this.isExecutionStatus(exec, statusMap, "blocked")) blocked++;
|
|
498
|
+
else if (this.isExecutionStatus(exec, statusMap, "notApplicable")) notApplicable++;
|
|
499
|
+
else notExecuted++;
|
|
349
500
|
}
|
|
350
501
|
|
|
351
502
|
const completedTests = passed + failed + notApplicable;
|
|
@@ -692,6 +843,53 @@ export class QualityGates {
|
|
|
692
843
|
}
|
|
693
844
|
}
|
|
694
845
|
|
|
846
|
+
// ─── Compare Releases ────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
async compareReleases(projectId, releaseId1, releaseId2, options = {}) {
|
|
849
|
+
const { query } = options;
|
|
850
|
+
const [readinessA, readinessB] = await Promise.all([
|
|
851
|
+
this.runAllGates(projectId, releaseId1, { query }),
|
|
852
|
+
this.runAllGates(projectId, releaseId2, { query }),
|
|
853
|
+
]);
|
|
854
|
+
|
|
855
|
+
// higherIsBetter: true means a larger metric value is the healthier direction
|
|
856
|
+
const gateMetrics = [
|
|
857
|
+
{ key: "requirementCoverage", metric: "coveragePercentage", higherIsBetter: true },
|
|
858
|
+
{ key: "testPlanAnalysis", metric: "overallPlanningPercentage", higherIsBetter: true },
|
|
859
|
+
{ key: "testExecution", metric: "executionPercentage", higherIsBetter: true },
|
|
860
|
+
{ key: "defectQuality", metric: "unresolvedDefects", higherIsBetter: false },
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
const gates = {};
|
|
864
|
+
for (const { key, metric, higherIsBetter } of gateMetrics) {
|
|
865
|
+
const gateA = readinessA.gates[key];
|
|
866
|
+
const gateB = readinessB.gates[key];
|
|
867
|
+
const valueA = gateA[metric];
|
|
868
|
+
const valueB = gateB[metric];
|
|
869
|
+
const delta = Math.round((valueB - valueA) * 100) / 100;
|
|
870
|
+
const trend = delta === 0 ? "unchanged" : (delta > 0) === higherIsBetter ? "improved" : "regressed";
|
|
871
|
+
|
|
872
|
+
gates[key] = {
|
|
873
|
+
metric,
|
|
874
|
+
releaseA: { status: gateA.status, value: valueA },
|
|
875
|
+
releaseB: { status: gateB.status, value: valueB },
|
|
876
|
+
delta,
|
|
877
|
+
statusChanged: gateA.status !== gateB.status,
|
|
878
|
+
trend,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
projectId,
|
|
884
|
+
timestamp: new Date().toISOString(),
|
|
885
|
+
query: query || undefined,
|
|
886
|
+
releaseA: { releaseId: releaseId1, ...readinessA.report, overallStatus: readinessA.overallStatus },
|
|
887
|
+
releaseB: { releaseId: releaseId2, ...readinessB.report, overallStatus: readinessB.overallStatus },
|
|
888
|
+
overallStatusChanged: readinessA.overallStatus !== readinessB.overallStatus,
|
|
889
|
+
gates,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
695
893
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
696
894
|
// TOOL 5: PROJECT HEALTH
|
|
697
895
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -916,6 +1114,7 @@ export class QualityGates {
|
|
|
916
1114
|
|
|
917
1115
|
async getFailedTests(projectId, releaseId, options = {}) {
|
|
918
1116
|
const { limit = 50, includeSteps = false } = options;
|
|
1117
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
919
1118
|
|
|
920
1119
|
// Get all executions
|
|
921
1120
|
const executionData = await this.GET("/execution", {
|
|
@@ -927,11 +1126,7 @@ export class QualityGates {
|
|
|
927
1126
|
|
|
928
1127
|
const executions = executionData.results || executionData || [];
|
|
929
1128
|
|
|
930
|
-
|
|
931
|
-
const failedExecutions = executions.filter(exec => {
|
|
932
|
-
const status = exec.lastTestResult?.executionStatus || exec.status || exec.executionStatus || 0;
|
|
933
|
-
return Number(status) === 2;
|
|
934
|
-
});
|
|
1129
|
+
const failedExecutions = executions.filter(exec => this.isExecutionStatus(exec, statusMap, "failed"));
|
|
935
1130
|
|
|
936
1131
|
// Build failed test list
|
|
937
1132
|
const failedTests = failedExecutions.slice(0, limit).map(exec => {
|
|
@@ -952,7 +1147,7 @@ export class QualityGates {
|
|
|
952
1147
|
// Summary stats
|
|
953
1148
|
const totalExecutions = executions.length;
|
|
954
1149
|
const failedCount = failedExecutions.length;
|
|
955
|
-
const passedCount = executions.filter(
|
|
1150
|
+
const passedCount = executions.filter(exec => this.isExecutionStatus(exec, statusMap, "passed")).length;
|
|
956
1151
|
|
|
957
1152
|
return {
|
|
958
1153
|
tool: "Failed Tests",
|
|
@@ -1052,6 +1247,7 @@ export class QualityGates {
|
|
|
1052
1247
|
|
|
1053
1248
|
async getTestCaseTrends(projectId, releaseId, options = {}) {
|
|
1054
1249
|
const { days = 30 } = options;
|
|
1250
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
1055
1251
|
|
|
1056
1252
|
// Calculate start date for ZQL query
|
|
1057
1253
|
const now = new Date();
|
|
@@ -1105,15 +1301,12 @@ export class QualityGates {
|
|
|
1105
1301
|
trendsByDate[dateKey] = { passed: 0, failed: 0, blocked: 0, wip: 0, total: 0 };
|
|
1106
1302
|
}
|
|
1107
1303
|
|
|
1108
|
-
const status = exec.status || '0';
|
|
1109
1304
|
trendsByDate[dateKey].total++;
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
case '4': trendsByDate[dateKey].blocked++; break;
|
|
1116
|
-
}
|
|
1305
|
+
|
|
1306
|
+
if (this.isExecutionStatus(exec, statusMap, "passed")) trendsByDate[dateKey].passed++;
|
|
1307
|
+
else if (this.isExecutionStatus(exec, statusMap, "failed")) trendsByDate[dateKey].failed++;
|
|
1308
|
+
else if (this.isExecutionStatus(exec, statusMap, "wip")) trendsByDate[dateKey].wip++;
|
|
1309
|
+
else if (this.isExecutionStatus(exec, statusMap, "blocked")) trendsByDate[dateKey].blocked++;
|
|
1117
1310
|
}
|
|
1118
1311
|
|
|
1119
1312
|
// Convert to sorted array
|
|
@@ -1281,6 +1474,7 @@ export class QualityGates {
|
|
|
1281
1474
|
|
|
1282
1475
|
async getUserActivity(projectId, releaseId, options = {}) {
|
|
1283
1476
|
const { days = 30 } = options;
|
|
1477
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
1284
1478
|
|
|
1285
1479
|
// Get executions to analyze user activity
|
|
1286
1480
|
const executionData = await this.GET("/execution", {
|
|
@@ -1335,7 +1529,7 @@ export class QualityGates {
|
|
|
1335
1529
|
const executorName = userCache[executorId] || "Unknown";
|
|
1336
1530
|
|
|
1337
1531
|
const executedOn = exec.lastTestResult?.executedOn || exec.executedOn;
|
|
1338
|
-
const
|
|
1532
|
+
const isExecuted = !this.isExecutionStatus(exec, statusMap, "unexecuted");
|
|
1339
1533
|
|
|
1340
1534
|
// Track assigned stats
|
|
1341
1535
|
if (!assignedStats[assignedName]) {
|
|
@@ -1352,17 +1546,15 @@ export class QualityGates {
|
|
|
1352
1546
|
}
|
|
1353
1547
|
assignedStats[assignedName].assigned++;
|
|
1354
1548
|
|
|
1355
|
-
if (
|
|
1549
|
+
if (isExecuted) {
|
|
1356
1550
|
assignedStats[assignedName].executed++;
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
case 4: assignedStats[assignedName].blocked++; break;
|
|
1361
|
-
}
|
|
1551
|
+
if (this.isExecutionStatus(exec, statusMap, "passed")) assignedStats[assignedName].passed++;
|
|
1552
|
+
else if (this.isExecutionStatus(exec, statusMap, "failed")) assignedStats[assignedName].failed++;
|
|
1553
|
+
else if (this.isExecutionStatus(exec, statusMap, "blocked")) assignedStats[assignedName].blocked++;
|
|
1362
1554
|
}
|
|
1363
1555
|
|
|
1364
1556
|
// Track executor stats (who actually ran tests)
|
|
1365
|
-
if (
|
|
1557
|
+
if (isExecuted && executorId && executorId > 0) {
|
|
1366
1558
|
if (!executorStats[executorName]) {
|
|
1367
1559
|
executorStats[executorName] = {
|
|
1368
1560
|
userId: executorId,
|
|
@@ -1376,11 +1568,9 @@ export class QualityGates {
|
|
|
1376
1568
|
}
|
|
1377
1569
|
|
|
1378
1570
|
executorStats[executorName].executed++;
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
case 4: executorStats[executorName].blocked++; break;
|
|
1383
|
-
}
|
|
1571
|
+
if (this.isExecutionStatus(exec, statusMap, "passed")) executorStats[executorName].passed++;
|
|
1572
|
+
else if (this.isExecutionStatus(exec, statusMap, "failed")) executorStats[executorName].failed++;
|
|
1573
|
+
else if (this.isExecutionStatus(exec, statusMap, "blocked")) executorStats[executorName].blocked++;
|
|
1384
1574
|
|
|
1385
1575
|
if (executedOn) {
|
|
1386
1576
|
const execDate = new Date(executedOn);
|
|
@@ -1413,7 +1603,51 @@ export class QualityGates {
|
|
|
1413
1603
|
totalPassed: acc.totalPassed + user.passed,
|
|
1414
1604
|
totalFailed: acc.totalFailed + user.failed,
|
|
1415
1605
|
}), { totalExecuted: 0, totalPassed: 0, totalFailed: 0 });
|
|
1416
|
-
|
|
1606
|
+
|
|
1607
|
+
// Team velocity trend — compare average daily executions across the
|
|
1608
|
+
// first and second half of the dates actually present in the data.
|
|
1609
|
+
const dailyCounts = {};
|
|
1610
|
+
for (const exec of executions) {
|
|
1611
|
+
if (this.isExecutionStatus(exec, statusMap, "unexecuted")) continue;
|
|
1612
|
+
const executedOn = exec.lastTestResult?.executedOn || exec.executedOn;
|
|
1613
|
+
if (!executedOn) continue;
|
|
1614
|
+
const dateKey = new Date(executedOn).toISOString().split('T')[0];
|
|
1615
|
+
dailyCounts[dateKey] = (dailyCounts[dateKey] || 0) + 1;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const sortedDates = Object.keys(dailyCounts).sort();
|
|
1619
|
+
let teamVelocityTrend = "insufficient data";
|
|
1620
|
+
let velocity = null;
|
|
1621
|
+
if (sortedDates.length >= 2) {
|
|
1622
|
+
const midpoint = Math.floor(sortedDates.length / 2);
|
|
1623
|
+
const firstHalfDates = sortedDates.slice(0, midpoint);
|
|
1624
|
+
const secondHalfDates = sortedDates.slice(midpoint);
|
|
1625
|
+
const firstHalfAvg = firstHalfDates.reduce((sum, d) => sum + dailyCounts[d], 0) / firstHalfDates.length;
|
|
1626
|
+
const secondHalfAvg = secondHalfDates.reduce((sum, d) => sum + dailyCounts[d], 0) / secondHalfDates.length;
|
|
1627
|
+
const changePct = firstHalfAvg > 0 ? Math.round(((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100) : null;
|
|
1628
|
+
|
|
1629
|
+
teamVelocityTrend = changePct === null
|
|
1630
|
+
? "insufficient data"
|
|
1631
|
+
: changePct > 10 ? "increasing" : changePct < -10 ? "decreasing" : "steady";
|
|
1632
|
+
velocity = {
|
|
1633
|
+
firstHalfAvgPerDay: Math.round(firstHalfAvg * 100) / 100,
|
|
1634
|
+
secondHalfAvgPerDay: Math.round(secondHalfAvg * 100) / 100,
|
|
1635
|
+
changePct,
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const avgCompletionRate = assignedUsers.length > 0
|
|
1640
|
+
? Math.round((assignedUsers.reduce((sum, u) => sum + u.completionRate, 0) / assignedUsers.length) * 100) / 100
|
|
1641
|
+
: 0;
|
|
1642
|
+
|
|
1643
|
+
// executors is sorted descending by executed count, and every entry has executed >= 1
|
|
1644
|
+
const mostActiveUser = executors.length > 0
|
|
1645
|
+
? { userId: executors[0].userId, name: executors[0].name, executed: executors[0].executed }
|
|
1646
|
+
: null;
|
|
1647
|
+
const leastActiveUser = executors.length > 0
|
|
1648
|
+
? { userId: executors[executors.length - 1].userId, name: executors[executors.length - 1].name, executed: executors[executors.length - 1].executed }
|
|
1649
|
+
: null;
|
|
1650
|
+
|
|
1417
1651
|
return {
|
|
1418
1652
|
tool: "User Activity",
|
|
1419
1653
|
projectId,
|
|
@@ -1429,6 +1663,13 @@ export class QualityGates {
|
|
|
1429
1663
|
? Math.round((teamSummary.totalPassed / teamSummary.totalExecuted) * 100)
|
|
1430
1664
|
: 0,
|
|
1431
1665
|
},
|
|
1666
|
+
trendSummary: {
|
|
1667
|
+
mostActiveUser,
|
|
1668
|
+
leastActiveUser,
|
|
1669
|
+
avgCompletionRate,
|
|
1670
|
+
teamVelocityTrend,
|
|
1671
|
+
velocity,
|
|
1672
|
+
},
|
|
1432
1673
|
assignedTo: assignedUsers,
|
|
1433
1674
|
executedBy: executors,
|
|
1434
1675
|
topExecutors: executors.slice(0, 5),
|
|
@@ -1439,16 +1680,37 @@ export class QualityGates {
|
|
|
1439
1680
|
|
|
1440
1681
|
async getExecutionBurndown(projectId, releaseId, options = {}) {
|
|
1441
1682
|
const { startDate = null, endDate = null } = options;
|
|
1683
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
1442
1684
|
|
|
1443
|
-
// Fetch all executions for the release
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1685
|
+
// Fetch all executions for the release, paginating until a page repeats
|
|
1686
|
+
// no new records. /execution's resultSize is unreliable (often 0) and its
|
|
1687
|
+
// offset can silently replay the last page instead of returning empty, so
|
|
1688
|
+
// pagination must stop based on de-duplicated record growth, not offsets.
|
|
1689
|
+
const executionsById = new Map();
|
|
1690
|
+
let currentOffset = 0;
|
|
1691
|
+
const pageSize = 10000;
|
|
1692
|
+
|
|
1693
|
+
while (true) {
|
|
1694
|
+
const executionData = await this.GET('/execution', {
|
|
1695
|
+
releaseid: releaseId,
|
|
1696
|
+
offset: currentOffset,
|
|
1697
|
+
pagesize: pageSize,
|
|
1698
|
+
includeanyoneuser: true,
|
|
1699
|
+
});
|
|
1700
|
+
const page = executionData.results || executionData || [];
|
|
1701
|
+
|
|
1702
|
+
if (!Array.isArray(page) || page.length === 0) break;
|
|
1703
|
+
|
|
1704
|
+
const sizeBefore = executionsById.size;
|
|
1705
|
+
for (const exec of page) executionsById.set(exec.id, exec);
|
|
1706
|
+
currentOffset += page.length;
|
|
1707
|
+
|
|
1708
|
+
if (executionsById.size === sizeBefore) break;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const executions = [...executionsById.values()];
|
|
1451
1712
|
const total = executions.length;
|
|
1713
|
+
const totalPlanned = total;
|
|
1452
1714
|
|
|
1453
1715
|
if (total === 0) {
|
|
1454
1716
|
return {
|
|
@@ -1457,6 +1719,7 @@ export class QualityGates {
|
|
|
1457
1719
|
releaseId,
|
|
1458
1720
|
timestamp: new Date().toISOString(),
|
|
1459
1721
|
total: 0,
|
|
1722
|
+
totalPlanned: 0,
|
|
1460
1723
|
message: 'No executions found for this release.',
|
|
1461
1724
|
dailyBurndown: [],
|
|
1462
1725
|
};
|
|
@@ -1478,9 +1741,7 @@ export class QualityGates {
|
|
|
1478
1741
|
if (!rawDate) continue;
|
|
1479
1742
|
const dateKey = new Date(rawDate).toISOString().split('T')[0];
|
|
1480
1743
|
if (dateKey < rangeStart || dateKey > rangeEnd) continue;
|
|
1481
|
-
|
|
1482
|
-
// Count as "executed" if not unexecuted (status 0)
|
|
1483
|
-
if (status !== '0') {
|
|
1744
|
+
if (!this.isExecutionStatus(exec, statusMap, "unexecuted")) {
|
|
1484
1745
|
executedByDate[dateKey] = (executedByDate[dateKey] || 0) + 1;
|
|
1485
1746
|
}
|
|
1486
1747
|
}
|
|
@@ -1526,6 +1787,7 @@ export class QualityGates {
|
|
|
1526
1787
|
timestamp: new Date().toISOString(),
|
|
1527
1788
|
dateRange: { from: rangeStart, to: rangeEnd },
|
|
1528
1789
|
total,
|
|
1790
|
+
totalPlanned,
|
|
1529
1791
|
summary: {
|
|
1530
1792
|
totalExecutions: total,
|
|
1531
1793
|
executed: lastDay.cumulativeExecuted,
|
|
@@ -1633,6 +1895,180 @@ export class QualityGates {
|
|
|
1633
1895
|
})),
|
|
1634
1896
|
};
|
|
1635
1897
|
}
|
|
1898
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1899
|
+
// TOOL: LIST USERS
|
|
1900
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1901
|
+
|
|
1902
|
+
async listUsers(projectId, options = {}) {
|
|
1903
|
+
const { pageSize = 50 } = options;
|
|
1904
|
+
const usersById = new Map();
|
|
1905
|
+
let currentOffset = 0;
|
|
1906
|
+
let resultSize = null;
|
|
1907
|
+
|
|
1908
|
+
// order=id keeps pages deterministic; without it, this endpoint returns
|
|
1909
|
+
// overlapping/shuffled records across pages.
|
|
1910
|
+
while (true) {
|
|
1911
|
+
const response = await this.GETv4(`/user/assignedProject/${projectId}`, {
|
|
1912
|
+
isLite: false,
|
|
1913
|
+
adminUser: false,
|
|
1914
|
+
pagesize: pageSize,
|
|
1915
|
+
offset: currentOffset,
|
|
1916
|
+
isPaginated: true,
|
|
1917
|
+
order: "id",
|
|
1918
|
+
isascorder: true,
|
|
1919
|
+
});
|
|
1920
|
+
const page = response.results || [];
|
|
1921
|
+
resultSize = response.resultSize ?? resultSize;
|
|
1922
|
+
|
|
1923
|
+
if (page.length === 0) break;
|
|
1924
|
+
|
|
1925
|
+
const sizeBefore = usersById.size;
|
|
1926
|
+
for (const user of page) usersById.set(user.id, user);
|
|
1927
|
+
currentOffset += page.length;
|
|
1928
|
+
|
|
1929
|
+
// Some Zephyr instances stop returning new records past an internal
|
|
1930
|
+
// offset cap even though resultSize is higher; stop rather than loop.
|
|
1931
|
+
if (usersById.size === sizeBefore) break;
|
|
1932
|
+
if (resultSize !== null && usersById.size >= resultSize) break;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
const users = [...usersById.values()];
|
|
1936
|
+
|
|
1937
|
+
return {
|
|
1938
|
+
tool: "List Users",
|
|
1939
|
+
projectId,
|
|
1940
|
+
timestamp: new Date().toISOString(),
|
|
1941
|
+
total: resultSize ?? users.length,
|
|
1942
|
+
returned: users.length,
|
|
1943
|
+
note: resultSize !== null && users.length < resultSize
|
|
1944
|
+
? `Zephyr reports ${resultSize} assigned users but only ${users.length} were reachable through pagination. This is a known API limitation, not a client-side truncation.`
|
|
1945
|
+
: undefined,
|
|
1946
|
+
users: users.map(user => ({
|
|
1947
|
+
id: user.id,
|
|
1948
|
+
fullName: user.fullName,
|
|
1949
|
+
userName: user.userName,
|
|
1950
|
+
email: user.email,
|
|
1951
|
+
title: user.title,
|
|
1952
|
+
location: user.location,
|
|
1953
|
+
accountEnabled: user.accountEnabled,
|
|
1954
|
+
roles: (user.roles || []).map(role => role.id),
|
|
1955
|
+
})),
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1960
|
+
// TOOL: LIST CYCLES
|
|
1961
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1962
|
+
|
|
1963
|
+
async getCycleStatusCounts(releaseId) {
|
|
1964
|
+
const raw = await this.GETv3(`/cycle/status/count/${releaseId}`, {
|
|
1965
|
+
isHideCycleEnabled: true,
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
// Each cycle maps phaseId -> statusCode -> count, plus a "-1" phase key
|
|
1969
|
+
// holding the cycle-level total across all its phases.
|
|
1970
|
+
const byCycleId = {};
|
|
1971
|
+
for (const [cycleId, phaseMap] of Object.entries(raw || {})) {
|
|
1972
|
+
const phases = {};
|
|
1973
|
+
let cycleTotal = null;
|
|
1974
|
+
for (const [phaseId, statusCounts] of Object.entries(phaseMap || {})) {
|
|
1975
|
+
const formatted = await this.formatExecutionStatusCounts(statusCounts);
|
|
1976
|
+
if (phaseId === "-1") cycleTotal = formatted;
|
|
1977
|
+
else phases[phaseId] = formatted;
|
|
1978
|
+
}
|
|
1979
|
+
byCycleId[cycleId] = { cycleTotal, phases };
|
|
1980
|
+
}
|
|
1981
|
+
return byCycleId;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
async formatExecutionStatusCounts(statusCounts) {
|
|
1985
|
+
const statusMap = await this.getTestResultStatusMap();
|
|
1986
|
+
const STATUS_LABELS = Object.fromEntries(
|
|
1987
|
+
Object.entries(statusMap).flatMap(([label, ids]) => [...ids].map(id => [id, label]))
|
|
1988
|
+
);
|
|
1989
|
+
const { "-1": total = 0, ...counts } = statusCounts || {};
|
|
1990
|
+
|
|
1991
|
+
return {
|
|
1992
|
+
total,
|
|
1993
|
+
breakdown: Object.entries(counts)
|
|
1994
|
+
.map(([statusCode, count]) => ({
|
|
1995
|
+
statusCode: Number(statusCode),
|
|
1996
|
+
label: STATUS_LABELS[statusCode] || `Custom Status ${statusCode}`,
|
|
1997
|
+
count,
|
|
1998
|
+
}))
|
|
1999
|
+
.sort((a, b) => b.count - a.count),
|
|
2000
|
+
};
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
async listCycles(releaseId) {
|
|
2004
|
+
const [cycles, statusCounts] = await Promise.all([
|
|
2005
|
+
this.GETv3(`/cycle/release/${releaseId}`, { sortKey: "name", isHideCycleEnabled: true }),
|
|
2006
|
+
this.getCycleStatusCounts(releaseId),
|
|
2007
|
+
]);
|
|
2008
|
+
|
|
2009
|
+
return {
|
|
2010
|
+
tool: "List Cycles",
|
|
2011
|
+
releaseId,
|
|
2012
|
+
timestamp: new Date().toISOString(),
|
|
2013
|
+
total: Array.isArray(cycles) ? cycles.length : 0,
|
|
2014
|
+
cycles: (cycles || []).map(cycle => {
|
|
2015
|
+
const cycleStatus = statusCounts[String(cycle.id)] || {};
|
|
2016
|
+
return {
|
|
2017
|
+
id: cycle.id,
|
|
2018
|
+
name: cycle.name,
|
|
2019
|
+
environment: cycle.environment,
|
|
2020
|
+
build: cycle.build,
|
|
2021
|
+
startDate: cycle.cycleStartDate,
|
|
2022
|
+
endDate: cycle.cycleEndDate,
|
|
2023
|
+
status: cycle.status,
|
|
2024
|
+
executionStatusCounts: cycleStatus.cycleTotal || null,
|
|
2025
|
+
phases: (cycle.cyclePhases || []).map(phase => ({
|
|
2026
|
+
id: phase.id,
|
|
2027
|
+
name: phase.name,
|
|
2028
|
+
startDate: phase.phaseStartDate,
|
|
2029
|
+
endDate: phase.phaseEndDate,
|
|
2030
|
+
executionStatusCounts: cycleStatus.phases?.[String(phase.id)] || null,
|
|
2031
|
+
})),
|
|
2032
|
+
};
|
|
2033
|
+
}),
|
|
2034
|
+
};
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2038
|
+
// TOOL: GET CYCLE
|
|
2039
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2040
|
+
|
|
2041
|
+
async getCycle(cycleId) {
|
|
2042
|
+
const cycle = await this.GETv3(`/cycle/${cycleId}`, {});
|
|
2043
|
+
const statusCounts = await this.getCycleStatusCounts(cycle.releaseId);
|
|
2044
|
+
const cycleStatus = statusCounts[String(cycleId)] || {};
|
|
2045
|
+
|
|
2046
|
+
return {
|
|
2047
|
+
tool: "Get Cycle",
|
|
2048
|
+
cycleId,
|
|
2049
|
+
timestamp: new Date().toISOString(),
|
|
2050
|
+
id: cycle.id,
|
|
2051
|
+
name: cycle.name,
|
|
2052
|
+
environment: cycle.environment,
|
|
2053
|
+
build: cycle.build,
|
|
2054
|
+
startDate: cycle.cycleStartDate,
|
|
2055
|
+
endDate: cycle.cycleEndDate,
|
|
2056
|
+
status: cycle.status,
|
|
2057
|
+
releaseId: cycle.releaseId,
|
|
2058
|
+
hasChild: cycle.hasChild,
|
|
2059
|
+
executionStatusCounts: cycleStatus.cycleTotal || null,
|
|
2060
|
+
phases: (cycle.cyclePhases || []).map(phase => ({
|
|
2061
|
+
id: phase.id,
|
|
2062
|
+
name: phase.name,
|
|
2063
|
+
startDate: phase.phaseStartDate,
|
|
2064
|
+
endDate: phase.phaseEndDate,
|
|
2065
|
+
freeForm: phase.freeForm,
|
|
2066
|
+
resetExecution: phase.resetExecution,
|
|
2067
|
+
hasChild: phase.hasChild,
|
|
2068
|
+
executionStatusCounts: cycleStatus.phases?.[String(phase.id)] || null,
|
|
2069
|
+
})),
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
1636
2072
|
}
|
|
1637
2073
|
|
|
1638
2074
|
// Export with both names for backward compatibility
|