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 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.2.8",
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
- const status = exec.lastTestResult?.executionStatus || exec.status || exec.executionStatus || 0;
341
- switch (Number(status)) {
342
- case 1: passed++; break;
343
- case 2: failed++; break;
344
- case 3: wip++; break;
345
- case 4: blocked++; break;
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
- // Filter to failed tests (status = 2)
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(e => Number(e.lastTestResult?.executionStatus || e.status || 0) === 1).length;
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
- switch (String(status)) {
1112
- case '1': trendsByDate[dateKey].passed++; break;
1113
- case '2': trendsByDate[dateKey].failed++; break;
1114
- case '3': trendsByDate[dateKey].wip++; break;
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 status = exec.lastTestResult?.executionStatus || exec.status || 0;
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 (Number(status) > 0) {
1549
+ if (isExecuted) {
1356
1550
  assignedStats[assignedName].executed++;
1357
- switch (Number(status)) {
1358
- case 1: assignedStats[assignedName].passed++; break;
1359
- case 2: assignedStats[assignedName].failed++; break;
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 (Number(status) > 0 && executorId && executorId > 0) {
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
- switch (Number(status)) {
1380
- case 1: executorStats[executorName].passed++; break;
1381
- case 2: executorStats[executorName].failed++; break;
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
- const executionData = await this.GET('/execution', {
1445
- releaseid: releaseId,
1446
- offset: 0,
1447
- pagesize: 10000,
1448
- includeanyoneuser: true,
1449
- });
1450
- const executions = executionData.results || executionData || [];
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
- const status = String(exec.lastTestResult?.executionStatus || exec.status || exec.executionStatus || '0');
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