zephyr-enterprise-tools 1.0.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.
@@ -0,0 +1,1129 @@
1
+ /**
2
+ * Zephyr Enterprise Tools Module
3
+ *
4
+ * Provides comprehensive tools for Zephyr Enterprise:
5
+ *
6
+ * RELEASE READINESS (Quality Gates):
7
+ * 1. Requirement Coverage Gate - Are requirements covered by tests?
8
+ * 2. Test Plan Analysis Gate - Are tests planned and assigned?
9
+ * 3. Test Execution Gate - Have tests been executed?
10
+ * 4. Defect Quality Gate - Are critical defects resolved?
11
+ *
12
+ * ANALYTICS & INSIGHTS:
13
+ * 5. Project Health - Overall project health metrics
14
+ * 6. Test Coverage - Detailed test coverage analysis
15
+ * 7. Failed Tests - Analyze and list failed tests
16
+ * 8. Requirement Coverage - Requirements without test coverage
17
+ * 9. Test Case Trends - Test case creation/execution trends
18
+ * 10. Search Test Cases - Search test cases by criteria
19
+ * 11. User Activity - User activity and productivity metrics
20
+ *
21
+ * Usage:
22
+ * import { ZephyrTools } from './quality-gates.js';
23
+ * const tools = new ZephyrTools({ baseUrl, username, password });
24
+ * const report = await tools.runAllGates(projectId, releaseId);
25
+ * const health = await tools.getProjectHealth(projectId, releaseId);
26
+ */
27
+
28
+ // ─── Configuration & Thresholds ───────────────────────────────────────────────
29
+
30
+ export const THRESHOLDS = {
31
+ requirementCoverage: {
32
+ go: 70,
33
+ description: "≥70% coverage = GO, <70% = NO GO"
34
+ },
35
+ testPlanAnalysis: {
36
+ noGo: 80,
37
+ conditionalGo: 90,
38
+ description: "<80% = NO GO, 80-90% = CONDITIONAL GO, ≥90% = GO"
39
+ },
40
+ testExecution: {
41
+ noGo: 90,
42
+ conditionalGo: 97,
43
+ description: "<90% = NO GO, 90-97% = CONDITIONAL GO, ≥97% = GO"
44
+ },
45
+ defectQuality: {
46
+ blockerLimit: 0,
47
+ highRiskLimit: 10,
48
+ description: "Blocker >0 = NO GO, High-risk >10 = NO GO, 1-10 = CONDITIONAL, 0 = GO"
49
+ }
50
+ };
51
+
52
+ export const RESOLVED_STATUSES = ['done', 'closed', 'ready for release', 'verified', 'resolved', 'fixed'];
53
+ export const BLOCKER_PRIORITIES = ['blocker', 'critical', 'p1', '1', 'highest'];
54
+ export const HIGH_RISK_PRIORITIES = ['high', 'medium', 'p2', 'p3', '2', '3'];
55
+ export const LOW_RISK_PRIORITIES = ['low', 'trivial', 'p4', 'p5', '4', '5', 'lowest', 'minor'];
56
+
57
+ // ─── Quality Gates Class ──────────────────────────────────────────────────────
58
+
59
+ export class QualityGates {
60
+ constructor(config) {
61
+ this.baseUrl = (config.baseUrl || process.env.ZEPHYR_BASE_URL || "").replace(/\/$/, "");
62
+ this.username = config.username || process.env.ZEPHYR_USERNAME || "";
63
+ this.password = config.password || process.env.ZEPHYR_PASSWORD || "";
64
+ this.token = config.token || process.env.ZEPHYR_TOKEN || "";
65
+
66
+ if (!this.baseUrl) {
67
+ throw new Error("ZEPHYR_BASE_URL is required");
68
+ }
69
+ }
70
+
71
+ // ─── HTTP Helper ────────────────────────────────────────────────────────────
72
+
73
+ authHeader() {
74
+ if (this.token) return { Authorization: `Bearer ${this.token}` };
75
+ return { Authorization: `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}` };
76
+ }
77
+
78
+ async request(method, path, params = {}) {
79
+ const url = new URL(`${this.baseUrl}${path}`);
80
+ for (const [k, v] of Object.entries(params)) {
81
+ if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
82
+ }
83
+ const res = await fetch(url.toString(), {
84
+ method,
85
+ headers: { Accept: "application/json", "Content-Type": "application/json", ...this.authHeader() },
86
+ });
87
+ if (!res.ok) {
88
+ const text = await res.text().catch(() => "");
89
+ throw new Error(`Zephyr API ${res.status}: ${text}`);
90
+ }
91
+ return res.json();
92
+ }
93
+
94
+ async GET(path, params) {
95
+ return this.request("GET", path, params);
96
+ }
97
+
98
+ async PUT(path, params, body) {
99
+ const url = new URL(`${this.baseUrl}${path}`);
100
+ for (const [k, v] of Object.entries(params)) {
101
+ if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
102
+ }
103
+ const res = await fetch(url.toString(), {
104
+ method: "PUT",
105
+ headers: { Accept: "application/json", "Content-Type": "application/json", ...this.authHeader() },
106
+ body: JSON.stringify(body),
107
+ });
108
+ if (!res.ok) {
109
+ const text = await res.text().catch(() => "");
110
+ throw new Error(`Zephyr API ${res.status}: ${text}`);
111
+ }
112
+ return res.json();
113
+ }
114
+
115
+ // ─── Gate 1: Requirement Coverage ───────────────────────────────────────────
116
+
117
+ async requirementCoverageGate(projectId, releaseId) {
118
+ const summary = await this.GET(`/summary/release/${releaseId}`, { isHideCycleEnabled: true });
119
+
120
+ const reqSummary = summary.requirement || {};
121
+ const total = reqSummary.totalRequirementCount || 0;
122
+ const covered = reqSummary.mappedRequirementCount || 0;
123
+ const notCovered = reqSummary.unmappedRequirementCount || 0;
124
+ const coveragePercentage = total > 0 ? Math.round((covered / total) * 100 * 100) / 100 : 0;
125
+
126
+ const threshold = THRESHOLDS.requirementCoverage.go;
127
+ const status = coveragePercentage >= threshold ? "GO" : "NO GO";
128
+ const statusMessage = coveragePercentage >= threshold
129
+ ? `Coverage is ${coveragePercentage}% (≥${threshold}%) - Ready for release`
130
+ : `Coverage is ${coveragePercentage}% (<${threshold}%) - Not ready. ${notCovered} requirements need test coverage.`;
131
+
132
+ return {
133
+ gate: "Requirement Coverage",
134
+ projectId,
135
+ releaseId,
136
+ totalRequirements: total,
137
+ coveredRequirements: covered,
138
+ notCoveredRequirements: notCovered,
139
+ coveragePercentage,
140
+ threshold,
141
+ status,
142
+ statusMessage,
143
+ };
144
+ }
145
+
146
+ // ─── Gate 2: Test Plan Analysis ─────────────────────────────────────────────
147
+
148
+ async testPlanAnalysisGate(projectId, releaseId) {
149
+ const summary = await this.GET(`/summary/release/${releaseId}`, { isHideCycleEnabled: false });
150
+
151
+ const totalTestcases = summary.testcase?.totalTestcaseCount || 0;
152
+ const mappedRequirements = summary.requirement?.mappedRequirementCount || 0;
153
+ const totalRequirements = summary.requirement?.totalRequirementCount || 0;
154
+
155
+ // Get all executions for the release
156
+ const executionData = await this.GET("/execution", {
157
+ releaseid: releaseId,
158
+ offset: 0,
159
+ pagesize: 10000,
160
+ includeanyoneuser: true,
161
+ });
162
+
163
+ const executions = executionData.results || executionData || [];
164
+ const totalExecutions = Array.isArray(executions) ? executions.length : 0;
165
+
166
+ // Count assigned executions
167
+ const assignedExecutions = Array.isArray(executions)
168
+ ? executions.filter(e => e.testerId && e.testerId > 0).length
169
+ : 0;
170
+
171
+ // Count unique planned test cases
172
+ const uniquePlannedTestcases = new Set(
173
+ Array.isArray(executions)
174
+ ? executions.map(e => e.tcrTreeTestcase?.testcase?.id || e.testcaseId).filter(Boolean)
175
+ : []
176
+ ).size;
177
+
178
+ // Calculate metrics
179
+ const testcasePlanningPct = totalTestcases > 0
180
+ ? Math.min(100, Math.round((uniquePlannedTestcases / totalTestcases) * 100 * 100) / 100)
181
+ : 0;
182
+
183
+ const executionAssignmentPct = totalExecutions > 0
184
+ ? Math.round((assignedExecutions / totalExecutions) * 100 * 100) / 100
185
+ : 0;
186
+
187
+ const overallPlanningPct = Math.round(((testcasePlanningPct + executionAssignmentPct) / 2) * 100) / 100;
188
+
189
+ // Determine status
190
+ const { noGo, conditionalGo } = THRESHOLDS.testPlanAnalysis;
191
+ let status, statusMessage;
192
+
193
+ if (overallPlanningPct < noGo) {
194
+ status = "NO GO";
195
+ statusMessage = `Overall planning is ${overallPlanningPct}% (<${noGo}%) - Not ready.`;
196
+ } else if (overallPlanningPct < conditionalGo) {
197
+ status = "CONDITIONAL GO";
198
+ statusMessage = `Overall planning is ${overallPlanningPct}% (${noGo}-${conditionalGo}%) - Proceed with caution.`;
199
+ } else {
200
+ status = "GO";
201
+ statusMessage = `Overall planning is ${overallPlanningPct}% (≥${conditionalGo}%) - Ready for execution.`;
202
+ }
203
+
204
+ return {
205
+ gate: "Test Plan Analysis",
206
+ projectId,
207
+ releaseId,
208
+ analysis: {
209
+ testcasePlanning: {
210
+ totalTestcases,
211
+ plannedTestcases: uniquePlannedTestcases,
212
+ percentage: testcasePlanningPct,
213
+ },
214
+ executionAssignment: {
215
+ totalExecutions,
216
+ assignedExecutions,
217
+ percentage: executionAssignmentPct,
218
+ },
219
+ requirementCoverage: {
220
+ totalRequirements,
221
+ mappedRequirements,
222
+ percentage: totalRequirements > 0 ? Math.round((mappedRequirements / totalRequirements) * 100 * 100) / 100 : 0,
223
+ },
224
+ },
225
+ overallPlanningPercentage: overallPlanningPct,
226
+ thresholds: { noGo, conditionalGo },
227
+ status,
228
+ statusMessage,
229
+ };
230
+ }
231
+
232
+ // ─── Gate 3: Test Execution Gate ────────────────────────────────────────────
233
+
234
+ async testExecutionGate(projectId, releaseId) {
235
+ const executionData = await this.GET("/execution", {
236
+ releaseid: releaseId,
237
+ offset: 0,
238
+ pagesize: 10000,
239
+ includeanyoneuser: true,
240
+ });
241
+
242
+ const executions = executionData.results || executionData || [];
243
+ const totalExecutions = Array.isArray(executions) ? executions.length : 0;
244
+
245
+ if (totalExecutions === 0) {
246
+ return {
247
+ gate: "Test Execution",
248
+ projectId,
249
+ releaseId,
250
+ totalPlannedTests: 0,
251
+ completedTests: 0,
252
+ executionPercentage: 0,
253
+ status: "NO GO",
254
+ statusMessage: "No test executions found for this release.",
255
+ breakdown: { passed: 0, failed: 0, notApplicable: 0, wip: 0, blocked: 0, notExecuted: 0 },
256
+ };
257
+ }
258
+
259
+ // Count by status (from lastTestResult.executionStatus)
260
+ let passed = 0, failed = 0, notApplicable = 0, wip = 0, blocked = 0, notExecuted = 0;
261
+
262
+ for (const exec of executions) {
263
+ const status = exec.lastTestResult?.executionStatus || exec.status || exec.executionStatus || 0;
264
+ switch (Number(status)) {
265
+ case 1: passed++; break;
266
+ case 2: failed++; break;
267
+ case 3: wip++; break;
268
+ case 4: blocked++; break;
269
+ case 5: notApplicable++; break;
270
+ default: notExecuted++; break;
271
+ }
272
+ }
273
+
274
+ const completedTests = passed + failed + notApplicable;
275
+ const incompleteTests = wip + blocked + notExecuted;
276
+ const executionPct = Math.round((completedTests / totalExecutions) * 100 * 100) / 100;
277
+
278
+ // Determine status
279
+ const { noGo, conditionalGo } = THRESHOLDS.testExecution;
280
+ let status, statusMessage;
281
+
282
+ if (executionPct < noGo) {
283
+ status = "NO GO";
284
+ statusMessage = `Execution is ${executionPct}% (<${noGo}%) - ${incompleteTests} tests need resolution.`;
285
+ } else if (executionPct < conditionalGo) {
286
+ status = "CONDITIONAL GO";
287
+ statusMessage = `Execution is ${executionPct}% (${noGo}-${conditionalGo}%) - ${incompleteTests} tests incomplete.`;
288
+ } else {
289
+ status = "GO";
290
+ statusMessage = `Execution is ${executionPct}% (≥${conditionalGo}%) - Gate passed.`;
291
+ }
292
+
293
+ return {
294
+ gate: "Test Execution",
295
+ projectId,
296
+ releaseId,
297
+ totalPlannedTests: totalExecutions,
298
+ completedTests,
299
+ incompleteTests,
300
+ executionPercentage: executionPct,
301
+ thresholds: { noGo, conditionalGo },
302
+ status,
303
+ statusMessage,
304
+ breakdown: { passed, failed, notApplicable, wip, blocked, notExecuted },
305
+ };
306
+ }
307
+
308
+ // ─── Gate 4: Defect Quality Gate ────────────────────────────────────────────
309
+
310
+ async defectQualityGate(projectId, releaseId) {
311
+ // Step 1: Get defect IDs from release summary
312
+ let defectIds = [];
313
+ try {
314
+ const summary = await this.GET(`/summary/release/${releaseId}`, {});
315
+ const defectSummary = summary.defect || {};
316
+ defectIds = defectSummary.totalDefectIds || [];
317
+
318
+ if (defectIds.length === 0) {
319
+ return {
320
+ gate: "Defect Quality",
321
+ projectId,
322
+ releaseId,
323
+ totalDefectsAnalyzed: 0,
324
+ unresolvedDefects: 0,
325
+ status: "GO",
326
+ statusMessage: "No defects found. Quality gate passed.",
327
+ };
328
+ }
329
+ } catch (e) {
330
+ return {
331
+ gate: "Defect Quality",
332
+ projectId,
333
+ releaseId,
334
+ status: "UNKNOWN",
335
+ statusMessage: "Unable to retrieve defect data from release summary.",
336
+ error: e.message,
337
+ };
338
+ }
339
+
340
+ // Step 2: Get full defect details using PUT /v3/defect with JQL search
341
+ // Note: Defect API uses v3, not latest - we need to adjust the path
342
+ let defects = [];
343
+ try {
344
+ const searchString = `issuekey in (${defectIds.join(',')})`;
345
+ // Build v3 URL by replacing /latest/ with /v3/ in baseUrl
346
+ const v3BaseUrl = this.baseUrl.replace('/latest', '/v3');
347
+ const url = new URL(`${v3BaseUrl}/defect`);
348
+ url.searchParams.set('offset', '0');
349
+ url.searchParams.set('maxresult', '100');
350
+ url.searchParams.set('searchtype', '1');
351
+ url.searchParams.set('maptc', 'true');
352
+ url.searchParams.set('projectId', String(projectId));
353
+ url.searchParams.set('order', 'id');
354
+ url.searchParams.set('isascorder', 'true');
355
+
356
+ const res = await fetch(url.toString(), {
357
+ method: 'PUT',
358
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...this.authHeader() },
359
+ body: JSON.stringify({ searchString }),
360
+ });
361
+
362
+ if (!res.ok) {
363
+ throw new Error(`Defect API ${res.status}`);
364
+ }
365
+
366
+ const defectData = await res.json();
367
+ // Response format: { bugsList: [...], maxResult: N }
368
+ defects = defectData?.bugsList || [];
369
+ if (!Array.isArray(defects)) defects = [];
370
+ } catch (e) {
371
+ // Fallback: return with IDs only (treat all as high-risk)
372
+ return {
373
+ gate: "Defect Quality",
374
+ projectId,
375
+ releaseId,
376
+ totalDefectsAnalyzed: defectIds.length,
377
+ unresolvedDefects: defectIds.length,
378
+ status: "CONDITIONAL GO",
379
+ statusMessage: `${defectIds.length} defect(s) found but details unavailable. Review manually.`,
380
+ defectIds,
381
+ note: "Could not fetch defect details. Treat as high-risk.",
382
+ error: e.message,
383
+ };
384
+ }
385
+
386
+ // Step 3: Classify defects by status and priority
387
+ let blockerCount = 0, highRiskCount = 0, lowRiskCount = 0, resolvedCount = 0;
388
+ const blockerDefects = [], highRiskDefects = [], lowRiskDefects = [], resolvedDefects = [];
389
+
390
+ for (const defect of defects) {
391
+ const status = (defect.status || '').toString().toLowerCase();
392
+ const priority = (defect.priority || '').toString().toLowerCase();
393
+ const isResolved = RESOLVED_STATUSES.some(rs => status.includes(rs));
394
+
395
+ const defectInfo = {
396
+ id: defect.alternateId || defect.id,
397
+ summary: defect.shortDesc || defect.name || defect.summary,
398
+ priority: defect.priority,
399
+ status: defect.status,
400
+ resolution: defect.resolution,
401
+ };
402
+
403
+ if (isResolved) {
404
+ resolvedCount++;
405
+ resolvedDefects.push(defectInfo);
406
+ } else if (BLOCKER_PRIORITIES.some(p => priority.includes(p))) {
407
+ blockerCount++;
408
+ blockerDefects.push(defectInfo);
409
+ } else if (HIGH_RISK_PRIORITIES.some(p => priority.includes(p))) {
410
+ highRiskCount++;
411
+ highRiskDefects.push(defectInfo);
412
+ } else if (LOW_RISK_PRIORITIES.some(p => priority.includes(p))) {
413
+ lowRiskCount++;
414
+ lowRiskDefects.push(defectInfo);
415
+ } else {
416
+ // Unknown priority - treat as high risk
417
+ highRiskCount++;
418
+ highRiskDefects.push({ ...defectInfo, note: 'Unknown priority - treated as high-risk' });
419
+ }
420
+ }
421
+
422
+ const unresolvedCount = blockerCount + highRiskCount + lowRiskCount;
423
+
424
+ // Step 4: Determine GO status
425
+ const { highRiskLimit } = THRESHOLDS.defectQuality;
426
+ let status, statusMessage;
427
+
428
+ if (blockerCount > 0) {
429
+ status = "NO GO";
430
+ statusMessage = `${blockerCount} blocker defect(s) found (${blockerDefects.map(d => d.id).join(', ')}). All must be resolved before release.`;
431
+ } else if (highRiskCount > highRiskLimit) {
432
+ status = "NO GO";
433
+ statusMessage = `${highRiskCount} high-risk defects (>${highRiskLimit} limit). Reduce before release.`;
434
+ } else if (highRiskCount > 0) {
435
+ status = "CONDITIONAL GO";
436
+ statusMessage = `${highRiskCount} high-risk defect(s) found (1-${highRiskLimit} allowed). Plan fixes.`;
437
+ } else if (lowRiskCount > 0) {
438
+ status = "GO";
439
+ statusMessage = `No blocker or high-risk defects. ${lowRiskCount} low-risk defect(s) are acceptable.`;
440
+ } else {
441
+ status = "GO";
442
+ statusMessage = `All ${resolvedCount} defect(s) are resolved. Quality gate passed.`;
443
+ }
444
+
445
+ return {
446
+ gate: "Defect Quality",
447
+ projectId,
448
+ releaseId,
449
+ totalDefectsAnalyzed: defects.length,
450
+ unresolvedDefects: unresolvedCount,
451
+ resolvedDefects: resolvedCount,
452
+ status,
453
+ statusMessage,
454
+ breakdown: {
455
+ blocker: { count: blockerCount, defects: blockerDefects },
456
+ highRisk: { count: highRiskCount, defects: highRiskDefects },
457
+ lowRisk: { count: lowRiskCount, defects: lowRiskDefects },
458
+ resolved: { count: resolvedCount, defects: resolvedDefects },
459
+ },
460
+ defectIds,
461
+ };
462
+ }
463
+
464
+ // ─── Run All Gates ──────────────────────────────────────────────────────────
465
+
466
+ async runAllGates(projectId, releaseId) {
467
+ const results = await Promise.all([
468
+ this.requirementCoverageGate(projectId, releaseId),
469
+ this.testPlanAnalysisGate(projectId, releaseId),
470
+ this.testExecutionGate(projectId, releaseId),
471
+ this.defectQualityGate(projectId, releaseId),
472
+ ]);
473
+
474
+ const gates = {
475
+ requirementCoverage: results[0],
476
+ testPlanAnalysis: results[1],
477
+ testExecution: results[2],
478
+ defectQuality: results[3],
479
+ };
480
+
481
+ // Calculate overall status
482
+ const statuses = results.map(r => r.status);
483
+ let overallStatus;
484
+
485
+ if (statuses.includes("NO GO")) {
486
+ overallStatus = "NO GO";
487
+ } else if (statuses.includes("CONDITIONAL GO")) {
488
+ overallStatus = "CONDITIONAL GO";
489
+ } else if (statuses.includes("UNKNOWN")) {
490
+ overallStatus = "CONDITIONAL GO";
491
+ } else {
492
+ overallStatus = "GO";
493
+ }
494
+
495
+ const passedGates = statuses.filter(s => s === "GO").length;
496
+ const failedGates = statuses.filter(s => s === "NO GO").length;
497
+ const conditionalGates = statuses.filter(s => s === "CONDITIONAL GO" || s === "UNKNOWN").length;
498
+
499
+ return {
500
+ projectId,
501
+ releaseId,
502
+ timestamp: new Date().toISOString(),
503
+ overallStatus,
504
+ summary: {
505
+ passed: passedGates,
506
+ failed: failedGates,
507
+ conditional: conditionalGates,
508
+ total: 4,
509
+ },
510
+ gates,
511
+ recommendation: this.getRecommendation(overallStatus, gates),
512
+ };
513
+ }
514
+
515
+ getRecommendation(status, gates) {
516
+ const issues = [];
517
+
518
+ if (gates.requirementCoverage.status === "NO GO") {
519
+ issues.push(`• Map tests to ${gates.requirementCoverage.notCoveredRequirements} uncovered requirements`);
520
+ }
521
+ if (gates.testPlanAnalysis.status === "NO GO") {
522
+ issues.push(`• Plan and assign test executions (current: ${gates.testPlanAnalysis.overallPlanningPercentage}%)`);
523
+ }
524
+ if (gates.testExecution.status === "NO GO") {
525
+ issues.push(`• Execute ${gates.testExecution.incompleteTests} incomplete tests`);
526
+ }
527
+ if (gates.defectQuality.status === "NO GO") {
528
+ const blocker = gates.defectQuality.breakdown?.blocker?.count || 0;
529
+ const highRisk = gates.defectQuality.breakdown?.highRisk?.count || 0;
530
+ if (blocker > 0) issues.push(`• Resolve ${blocker} blocker defect(s)`);
531
+ if (highRisk > 10) issues.push(`• Reduce high-risk defects from ${highRisk} to ≤10`);
532
+ }
533
+
534
+ if (status === "GO") {
535
+ return "✅ All quality gates passed. Release is ready.";
536
+ } else if (status === "CONDITIONAL GO") {
537
+ return "⚠️ Proceed with caution. Minor issues exist but release is possible.";
538
+ } else {
539
+ return `🚨 Release blocked. Action items:\n${issues.join('\n')}`;
540
+ }
541
+ }
542
+
543
+ // ═══════════════════════════════════════════════════════════════════════════
544
+ // TOOL 5: PROJECT HEALTH
545
+ // ═══════════════════════════════════════════════════════════════════════════
546
+
547
+ async getProjectHealth(projectId, releaseId) {
548
+ const summary = await this.GET(`/summary/release/${releaseId}`, { isHideCycleEnabled: false });
549
+
550
+ // Requirements metrics
551
+ const reqData = summary.requirement || {};
552
+ const totalReqs = reqData.totalRequirementCount || 0;
553
+ const mappedReqs = reqData.mappedRequirementCount || 0;
554
+ const reqCoverage = totalReqs > 0 ? Math.round((mappedReqs / totalReqs) * 100 * 100) / 100 : 0;
555
+
556
+ // Test case metrics
557
+ const tcData = summary.testcase || {};
558
+ const totalTestcases = tcData.totalTestcaseCount || 0;
559
+
560
+ // Execution metrics
561
+ const execData = summary.execution || {};
562
+ const totalExecutions = execData.totalExecutionCount || 0;
563
+ const passedCount = execData.passedExecutionCount || 0;
564
+ const failedCount = execData.failedExecutionCount || 0;
565
+ const blockedCount = execData.blockedExecutionCount || 0;
566
+ const wipCount = execData.wipExecutionCount || 0;
567
+ const notExecutedCount = execData.unexecutedCount || 0;
568
+
569
+ const completedExecutions = passedCount + failedCount;
570
+ const executionRate = totalExecutions > 0 ? Math.round((completedExecutions / totalExecutions) * 100 * 100) / 100 : 0;
571
+ const passRate = completedExecutions > 0 ? Math.round((passedCount / completedExecutions) * 100 * 100) / 100 : 0;
572
+
573
+ // Defect metrics
574
+ const defectData = summary.defect || {};
575
+ const totalDefects = defectData.totalDefectCount || 0;
576
+ const openDefects = defectData.openDefectCount || totalDefects;
577
+
578
+ // Calculate health score (0-100)
579
+ const reqScore = reqCoverage;
580
+ const execScore = executionRate;
581
+ const passScore = passRate;
582
+ const defectPenalty = Math.min(30, openDefects * 3); // Penalty for open defects
583
+
584
+ const healthScore = Math.max(0, Math.round(
585
+ (reqScore * 0.25 + execScore * 0.35 + passScore * 0.40) - defectPenalty
586
+ ));
587
+
588
+ let healthStatus;
589
+ if (healthScore >= 80) healthStatus = "HEALTHY";
590
+ else if (healthScore >= 60) healthStatus = "MODERATE";
591
+ else if (healthScore >= 40) healthStatus = "AT RISK";
592
+ else healthStatus = "CRITICAL";
593
+
594
+ return {
595
+ tool: "Project Health",
596
+ projectId,
597
+ releaseId,
598
+ timestamp: new Date().toISOString(),
599
+ healthScore,
600
+ healthStatus,
601
+ metrics: {
602
+ requirements: {
603
+ total: totalReqs,
604
+ covered: mappedReqs,
605
+ uncovered: totalReqs - mappedReqs,
606
+ coveragePercentage: reqCoverage,
607
+ },
608
+ testCases: {
609
+ total: totalTestcases,
610
+ },
611
+ executions: {
612
+ total: totalExecutions,
613
+ passed: passedCount,
614
+ failed: failedCount,
615
+ blocked: blockedCount,
616
+ wip: wipCount,
617
+ notExecuted: notExecutedCount,
618
+ executionRate,
619
+ passRate,
620
+ },
621
+ defects: {
622
+ total: totalDefects,
623
+ open: openDefects,
624
+ },
625
+ },
626
+ recommendations: this.getHealthRecommendations(healthScore, reqCoverage, executionRate, passRate, openDefects),
627
+ };
628
+ }
629
+
630
+ getHealthRecommendations(score, reqCoverage, execRate, passRate, openDefects) {
631
+ const recs = [];
632
+ if (reqCoverage < 70) recs.push(`Improve requirement coverage from ${reqCoverage}% to ≥70%`);
633
+ if (execRate < 90) recs.push(`Execute more tests - current rate is ${execRate}%`);
634
+ if (passRate < 80) recs.push(`Investigate failing tests - pass rate is ${passRate}%`);
635
+ if (openDefects > 5) recs.push(`Resolve ${openDefects} open defects`);
636
+ if (recs.length === 0) recs.push("Project is in good health!");
637
+ return recs;
638
+ }
639
+
640
+ // ═══════════════════════════════════════════════════════════════════════════
641
+ // TOOL 6: TEST COVERAGE
642
+ // ═══════════════════════════════════════════════════════════════════════════
643
+
644
+ async getTestCoverage(projectId, releaseId) {
645
+ const summary = await this.GET(`/summary/release/${releaseId}`, { isHideCycleEnabled: false });
646
+
647
+ const reqData = summary.requirement || {};
648
+ const tcData = summary.testcase || {};
649
+ const execData = summary.execution || {};
650
+
651
+ const totalRequirements = reqData.totalRequirementCount || 0;
652
+ const coveredRequirements = reqData.mappedRequirementCount || 0;
653
+ const uncoveredRequirements = reqData.unmappedRequirementCount || 0;
654
+
655
+ const totalTestcases = tcData.totalTestcaseCount || 0;
656
+ const totalExecutions = execData.totalExecutionCount || 0;
657
+
658
+ // Calculate coverage metrics
659
+ const requirementCoverage = totalRequirements > 0
660
+ ? Math.round((coveredRequirements / totalRequirements) * 100 * 100) / 100
661
+ : 0;
662
+
663
+ const testcaseToReqRatio = totalRequirements > 0
664
+ ? Math.round((totalTestcases / totalRequirements) * 100) / 100
665
+ : 0;
666
+
667
+ const executionCoverage = totalTestcases > 0
668
+ ? Math.round((totalExecutions / totalTestcases) * 100 * 100) / 100
669
+ : 0;
670
+
671
+ return {
672
+ tool: "Test Coverage",
673
+ projectId,
674
+ releaseId,
675
+ timestamp: new Date().toISOString(),
676
+ summary: {
677
+ requirementCoveragePercentage: requirementCoverage,
678
+ executionCoveragePercentage: executionCoverage,
679
+ testcaseToRequirementRatio: testcaseToReqRatio,
680
+ },
681
+ details: {
682
+ requirements: {
683
+ total: totalRequirements,
684
+ covered: coveredRequirements,
685
+ uncovered: uncoveredRequirements,
686
+ },
687
+ testCases: {
688
+ total: totalTestcases,
689
+ avgPerRequirement: testcaseToReqRatio,
690
+ },
691
+ executions: {
692
+ total: totalExecutions,
693
+ },
694
+ },
695
+ status: requirementCoverage >= 70 ? "ADEQUATE" : requirementCoverage >= 50 ? "PARTIAL" : "INSUFFICIENT",
696
+ message: requirementCoverage >= 70
697
+ ? `Good coverage at ${requirementCoverage}%`
698
+ : `Coverage is ${requirementCoverage}%. ${uncoveredRequirements} requirements need test cases.`,
699
+ };
700
+ }
701
+
702
+ // ═══════════════════════════════════════════════════════════════════════════
703
+ // TOOL 7: FAILED TESTS
704
+ // ═══════════════════════════════════════════════════════════════════════════
705
+
706
+ async getFailedTests(projectId, releaseId, options = {}) {
707
+ const { limit = 50, includeSteps = false } = options;
708
+
709
+ // Get all executions
710
+ const executionData = await this.GET("/execution", {
711
+ releaseid: releaseId,
712
+ offset: 0,
713
+ pagesize: 10000,
714
+ includeanyoneuser: true,
715
+ });
716
+
717
+ const executions = executionData.results || executionData || [];
718
+
719
+ // Filter to failed tests (status = 2)
720
+ const failedExecutions = executions.filter(exec => {
721
+ const status = exec.lastTestResult?.executionStatus || exec.status || exec.executionStatus || 0;
722
+ return Number(status) === 2;
723
+ });
724
+
725
+ // Build failed test list
726
+ const failedTests = failedExecutions.slice(0, limit).map(exec => {
727
+ const tc = exec.tcrTreeTestcase?.testcase || {};
728
+ return {
729
+ executionId: exec.id,
730
+ testcaseId: tc.id || exec.testcaseId,
731
+ testcaseName: tc.name || exec.name || "Unknown",
732
+ testcaseKey: tc.testcaseKey || tc.alternateId,
733
+ lastExecutedOn: exec.lastTestResult?.executedOn || exec.executedOn,
734
+ executedBy: exec.lastTestResult?.testerName || exec.testerName,
735
+ cycleName: exec.tcrTreeTestcase?.tcrCatalogTreeId?.name || exec.cycleName,
736
+ cyclePhase: exec.cyclePhase?.name,
737
+ defects: exec.lastTestResult?.defects || [],
738
+ };
739
+ });
740
+
741
+ // Summary stats
742
+ const totalExecutions = executions.length;
743
+ const failedCount = failedExecutions.length;
744
+ const passedCount = executions.filter(e => Number(e.lastTestResult?.executionStatus || e.status || 0) === 1).length;
745
+
746
+ return {
747
+ tool: "Failed Tests",
748
+ projectId,
749
+ releaseId,
750
+ timestamp: new Date().toISOString(),
751
+ summary: {
752
+ totalExecutions,
753
+ failedCount,
754
+ passedCount,
755
+ failureRate: totalExecutions > 0 ? Math.round((failedCount / totalExecutions) * 100 * 100) / 100 : 0,
756
+ },
757
+ failedTests,
758
+ message: failedCount === 0
759
+ ? "No failed tests found!"
760
+ : `${failedCount} test(s) failed out of ${totalExecutions} (${Math.round((failedCount / totalExecutions) * 100)}%)`,
761
+ };
762
+ }
763
+
764
+ // ═══════════════════════════════════════════════════════════════════════════
765
+ // TOOL 8: REQUIREMENT COVERAGE (Detailed)
766
+ // ═══════════════════════════════════════════════════════════════════════════
767
+
768
+ async getRequirementCoverage(projectId, releaseId) {
769
+ // Get release summary
770
+ const summary = await this.GET(`/summary/release/${releaseId}`, {});
771
+
772
+ const reqData = summary.requirement || {};
773
+ const totalRequirements = reqData.totalRequirementCount || 0;
774
+ const coveredRequirements = reqData.mappedRequirementCount || 0;
775
+ const uncoveredRequirements = reqData.unmappedRequirementCount || 0;
776
+
777
+ // Try to get requirements list
778
+ let requirements = [];
779
+ try {
780
+ const reqList = await this.GET("/requirement", {
781
+ projectId: projectId,
782
+ releaseId: releaseId,
783
+ offset: 0,
784
+ maxRecords: 500,
785
+ });
786
+ requirements = reqList.results || reqList || [];
787
+ } catch (e) {
788
+ // Requirement list may not be available
789
+ }
790
+
791
+ // Categorize requirements
792
+ const covered = [];
793
+ const uncovered = [];
794
+
795
+ for (const req of requirements) {
796
+ const reqInfo = {
797
+ id: req.id,
798
+ key: req.externalId || req.alternateId || req.requirementKey,
799
+ name: req.name,
800
+ priority: req.priority,
801
+ testcaseCount: req.testcaseCount || 0,
802
+ };
803
+
804
+ if (req.testcaseCount > 0) {
805
+ covered.push(reqInfo);
806
+ } else {
807
+ uncovered.push(reqInfo);
808
+ }
809
+ }
810
+
811
+ const coveragePercentage = totalRequirements > 0
812
+ ? Math.round((coveredRequirements / totalRequirements) * 100 * 100) / 100
813
+ : 0;
814
+
815
+ return {
816
+ tool: "Requirement Coverage",
817
+ projectId,
818
+ releaseId,
819
+ timestamp: new Date().toISOString(),
820
+ summary: {
821
+ total: totalRequirements,
822
+ covered: coveredRequirements,
823
+ uncovered: uncoveredRequirements,
824
+ coveragePercentage,
825
+ },
826
+ status: coveragePercentage >= 70 ? "GO" : "NO GO",
827
+ message: coveragePercentage >= 70
828
+ ? `Coverage is ${coveragePercentage}% - meets threshold`
829
+ : `Coverage is ${coveragePercentage}% - ${uncoveredRequirements} requirements need test coverage`,
830
+ details: {
831
+ coveredRequirements: covered.slice(0, 20),
832
+ uncoveredRequirements: uncovered.slice(0, 20),
833
+ note: requirements.length > 40 ? "Showing first 20 of each category" : undefined,
834
+ },
835
+ };
836
+ }
837
+
838
+ // ═══════════════════════════════════════════════════════════════════════════
839
+ // TOOL 9: TEST CASE TRENDS
840
+ // ═══════════════════════════════════════════════════════════════════════════
841
+
842
+ async getTestCaseTrends(projectId, releaseId, options = {}) {
843
+ const { days = 30 } = options;
844
+
845
+ // Get executions
846
+ const executionData = await this.GET("/execution", {
847
+ releaseid: releaseId,
848
+ offset: 0,
849
+ pagesize: 10000,
850
+ includeanyoneuser: true,
851
+ });
852
+
853
+ const executions = executionData.results || executionData || [];
854
+
855
+ // Group executions by date
856
+ const trendsByDate = {};
857
+ const now = new Date();
858
+ const startDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
859
+
860
+ for (const exec of executions) {
861
+ const executedOn = exec.lastTestResult?.executedOn || exec.executedOn;
862
+ if (!executedOn) continue;
863
+
864
+ const execDate = new Date(executedOn);
865
+ if (execDate < startDate) continue;
866
+
867
+ const dateKey = execDate.toISOString().split('T')[0];
868
+
869
+ if (!trendsByDate[dateKey]) {
870
+ trendsByDate[dateKey] = { passed: 0, failed: 0, blocked: 0, wip: 0, total: 0 };
871
+ }
872
+
873
+ const status = exec.lastTestResult?.executionStatus || exec.status || 0;
874
+ trendsByDate[dateKey].total++;
875
+
876
+ switch (Number(status)) {
877
+ case 1: trendsByDate[dateKey].passed++; break;
878
+ case 2: trendsByDate[dateKey].failed++; break;
879
+ case 3: trendsByDate[dateKey].wip++; break;
880
+ case 4: trendsByDate[dateKey].blocked++; break;
881
+ }
882
+ }
883
+
884
+ // Convert to sorted array
885
+ const trends = Object.entries(trendsByDate)
886
+ .sort(([a], [b]) => a.localeCompare(b))
887
+ .map(([date, data]) => ({ date, ...data }));
888
+
889
+ // Calculate totals
890
+ const totals = trends.reduce((acc, day) => ({
891
+ passed: acc.passed + day.passed,
892
+ failed: acc.failed + day.failed,
893
+ blocked: acc.blocked + day.blocked,
894
+ wip: acc.wip + day.wip,
895
+ total: acc.total + day.total,
896
+ }), { passed: 0, failed: 0, blocked: 0, wip: 0, total: 0 });
897
+
898
+ return {
899
+ tool: "Test Case Trends",
900
+ projectId,
901
+ releaseId,
902
+ timestamp: new Date().toISOString(),
903
+ period: {
904
+ days,
905
+ from: startDate.toISOString().split('T')[0],
906
+ to: now.toISOString().split('T')[0],
907
+ },
908
+ summary: {
909
+ totalExecutionsInPeriod: totals.total,
910
+ passed: totals.passed,
911
+ failed: totals.failed,
912
+ blocked: totals.blocked,
913
+ wip: totals.wip,
914
+ avgExecutionsPerDay: trends.length > 0 ? Math.round(totals.total / trends.length) : 0,
915
+ },
916
+ dailyTrends: trends,
917
+ insight: totals.failed > totals.passed
918
+ ? "⚠️ More failures than passes - investigate test stability"
919
+ : totals.total === 0
920
+ ? "No test activity in this period"
921
+ : `✅ ${Math.round((totals.passed / totals.total) * 100)}% pass rate over ${trends.length} active days`,
922
+ };
923
+ }
924
+
925
+ // ═══════════════════════════════════════════════════════════════════════════
926
+ // TOOL 10: SEARCH TEST CASES
927
+ // ═══════════════════════════════════════════════════════════════════════════
928
+
929
+ async searchTestCases(projectId, releaseId, options = {}) {
930
+ const { query = '', status, priority, limit = 50 } = options;
931
+
932
+ // Get test cases
933
+ const params = {
934
+ projectId: projectId,
935
+ releaseId: releaseId,
936
+ offset: 0,
937
+ maxRecords: 500,
938
+ };
939
+
940
+ if (query) params.word = query;
941
+
942
+ let testcases = [];
943
+ try {
944
+ const tcData = await this.GET("/testcase/tree", params);
945
+ testcases = tcData.results || tcData || [];
946
+
947
+ // Flatten tree structure if needed
948
+ if (!Array.isArray(testcases)) {
949
+ testcases = this.flattenTestcaseTree(tcData);
950
+ }
951
+ } catch (e) {
952
+ // Try alternative endpoint
953
+ try {
954
+ const tcData = await this.GET("/testcase", params);
955
+ testcases = tcData.results || tcData || [];
956
+ } catch (e2) {
957
+ return {
958
+ tool: "Search Test Cases",
959
+ projectId,
960
+ releaseId,
961
+ error: "Unable to fetch test cases",
962
+ results: [],
963
+ };
964
+ }
965
+ }
966
+
967
+ // Filter results
968
+ let filtered = testcases;
969
+
970
+ if (query) {
971
+ const q = query.toLowerCase();
972
+ filtered = filtered.filter(tc =>
973
+ (tc.name || '').toLowerCase().includes(q) ||
974
+ (tc.testcaseKey || tc.alternateId || '').toLowerCase().includes(q) ||
975
+ (tc.description || '').toLowerCase().includes(q)
976
+ );
977
+ }
978
+
979
+ if (status) {
980
+ filtered = filtered.filter(tc =>
981
+ (tc.status || '').toLowerCase() === status.toLowerCase()
982
+ );
983
+ }
984
+
985
+ if (priority) {
986
+ filtered = filtered.filter(tc =>
987
+ (tc.priority || '').toLowerCase() === priority.toLowerCase()
988
+ );
989
+ }
990
+
991
+ // Map to clean output
992
+ const results = filtered.slice(0, limit).map(tc => ({
993
+ id: tc.id,
994
+ key: tc.testcaseKey || tc.alternateId,
995
+ name: tc.name,
996
+ status: tc.status,
997
+ priority: tc.priority,
998
+ automated: tc.automated || tc.isAutomated || false,
999
+ folder: tc.folderPath || tc.tcrCatalogTreeId?.name,
1000
+ estimatedTime: tc.estimatedTime,
1001
+ tags: tc.tags || [],
1002
+ }));
1003
+
1004
+ return {
1005
+ tool: "Search Test Cases",
1006
+ projectId,
1007
+ releaseId,
1008
+ timestamp: new Date().toISOString(),
1009
+ query: { text: query, status, priority },
1010
+ totalMatches: filtered.length,
1011
+ returned: results.length,
1012
+ results,
1013
+ };
1014
+ }
1015
+
1016
+ flattenTestcaseTree(node, results = []) {
1017
+ if (node.testcase) results.push(node.testcase);
1018
+ if (node.testcases) results.push(...node.testcases);
1019
+ if (node.children) {
1020
+ for (const child of node.children) {
1021
+ this.flattenTestcaseTree(child, results);
1022
+ }
1023
+ }
1024
+ return results;
1025
+ }
1026
+
1027
+ // ═══════════════════════════════════════════════════════════════════════════
1028
+ // TOOL 11: USER ACTIVITY
1029
+ // ═══════════════════════════════════════════════════════════════════════════
1030
+
1031
+ async getUserActivity(projectId, releaseId, options = {}) {
1032
+ const { days = 30 } = options;
1033
+
1034
+ // Get executions to analyze user activity
1035
+ const executionData = await this.GET("/execution", {
1036
+ releaseid: releaseId,
1037
+ offset: 0,
1038
+ pagesize: 10000,
1039
+ includeanyoneuser: true,
1040
+ });
1041
+
1042
+ const executions = executionData.results || executionData || [];
1043
+
1044
+ // Aggregate by user
1045
+ const userStats = {};
1046
+ const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
1047
+
1048
+ for (const exec of executions) {
1049
+ const testerName = exec.lastTestResult?.testerName || exec.testerName || "Unassigned";
1050
+ const testerId = exec.testerId || exec.lastTestResult?.testerId;
1051
+ const executedOn = exec.lastTestResult?.executedOn || exec.executedOn;
1052
+
1053
+ if (!userStats[testerName]) {
1054
+ userStats[testerName] = {
1055
+ userId: testerId,
1056
+ name: testerName,
1057
+ assigned: 0,
1058
+ executed: 0,
1059
+ passed: 0,
1060
+ failed: 0,
1061
+ blocked: 0,
1062
+ lastActivity: null,
1063
+ };
1064
+ }
1065
+
1066
+ userStats[testerName].assigned++;
1067
+
1068
+ const status = exec.lastTestResult?.executionStatus || exec.status || 0;
1069
+ if (Number(status) > 0) {
1070
+ userStats[testerName].executed++;
1071
+
1072
+ switch (Number(status)) {
1073
+ case 1: userStats[testerName].passed++; break;
1074
+ case 2: userStats[testerName].failed++; break;
1075
+ case 4: userStats[testerName].blocked++; break;
1076
+ }
1077
+
1078
+ if (executedOn) {
1079
+ const execDate = new Date(executedOn);
1080
+ if (!userStats[testerName].lastActivity || execDate > new Date(userStats[testerName].lastActivity)) {
1081
+ userStats[testerName].lastActivity = executedOn;
1082
+ }
1083
+ }
1084
+ }
1085
+ }
1086
+
1087
+ // Convert to array and calculate metrics
1088
+ const users = Object.values(userStats)
1089
+ .map(user => ({
1090
+ ...user,
1091
+ completionRate: user.assigned > 0 ? Math.round((user.executed / user.assigned) * 100) : 0,
1092
+ passRate: user.executed > 0 ? Math.round((user.passed / user.executed) * 100) : 0,
1093
+ }))
1094
+ .sort((a, b) => b.executed - a.executed);
1095
+
1096
+ // Team summary
1097
+ const teamSummary = users.reduce((acc, user) => ({
1098
+ totalAssigned: acc.totalAssigned + user.assigned,
1099
+ totalExecuted: acc.totalExecuted + user.executed,
1100
+ totalPassed: acc.totalPassed + user.passed,
1101
+ totalFailed: acc.totalFailed + user.failed,
1102
+ }), { totalAssigned: 0, totalExecuted: 0, totalPassed: 0, totalFailed: 0 });
1103
+
1104
+ return {
1105
+ tool: "User Activity",
1106
+ projectId,
1107
+ releaseId,
1108
+ timestamp: new Date().toISOString(),
1109
+ period: { days },
1110
+ teamSummary: {
1111
+ ...teamSummary,
1112
+ activeUsers: users.filter(u => u.executed > 0).length,
1113
+ totalUsers: users.length,
1114
+ teamCompletionRate: teamSummary.totalAssigned > 0
1115
+ ? Math.round((teamSummary.totalExecuted / teamSummary.totalAssigned) * 100)
1116
+ : 0,
1117
+ teamPassRate: teamSummary.totalExecuted > 0
1118
+ ? Math.round((teamSummary.totalPassed / teamSummary.totalExecuted) * 100)
1119
+ : 0,
1120
+ },
1121
+ users,
1122
+ topPerformers: users.filter(u => u.executed > 0).slice(0, 5),
1123
+ };
1124
+ }
1125
+ }
1126
+
1127
+ // Export with both names for backward compatibility
1128
+ export { QualityGates as ZephyrTools };
1129
+ export default QualityGates;