gha-workflow-testing 1.0.0 → 1.1.1

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.
Files changed (42) hide show
  1. package/README.md +16 -19
  2. package/dist/check_run.d.ts +20 -0
  3. package/dist/check_run.d.ts.map +1 -0
  4. package/dist/check_run.js +110 -0
  5. package/dist/check_run.js.map +1 -0
  6. package/dist/github-api.d.ts +2 -0
  7. package/dist/github-api.d.ts.map +1 -1
  8. package/dist/github-api.js +37 -11
  9. package/dist/github-api.js.map +1 -1
  10. package/dist/global_setup.d.ts +9 -0
  11. package/dist/global_setup.d.ts.map +1 -0
  12. package/dist/global_setup.js +50 -0
  13. package/dist/global_setup.js.map +1 -0
  14. package/dist/global_teardown.d.ts +10 -0
  15. package/dist/global_teardown.d.ts.map +1 -0
  16. package/dist/global_teardown.js +77 -0
  17. package/dist/global_teardown.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/reporter.d.ts +28 -0
  23. package/dist/reporter.d.ts.map +1 -1
  24. package/dist/reporter.js +106 -51
  25. package/dist/reporter.js.map +1 -1
  26. package/dist/reusable_reporter.d.ts +211 -0
  27. package/dist/reusable_reporter.d.ts.map +1 -0
  28. package/dist/reusable_reporter.js +749 -0
  29. package/dist/reusable_reporter.js.map +1 -0
  30. package/dist/workflow_status.d.ts +57 -0
  31. package/dist/workflow_status.d.ts.map +1 -0
  32. package/dist/workflow_status.js +165 -0
  33. package/dist/workflow_status.js.map +1 -0
  34. package/package.json +6 -7
  35. package/src/check_run.ts +122 -0
  36. package/src/github-api.ts +44 -12
  37. package/src/global_setup.ts +16 -0
  38. package/src/global_teardown.ts +53 -0
  39. package/src/index.ts +3 -0
  40. package/src/reporter.ts +121 -21
  41. package/src/reusable_reporter.ts +890 -0
  42. package/src/workflow_status.ts +181 -0
@@ -0,0 +1,890 @@
1
+ /**
2
+ * reusable_reporter.ts
3
+ *
4
+ * Aggregating reporter that groups GitHub Actions jobs and steps by the
5
+ * reusable workflow that defined them. Extends WorkflowTestReporter from
6
+ * reporter.ts so all existing recording/assertion helpers still work; this
7
+ * file only adds the aggregation and grouped-output layer on top.
8
+ *
9
+ * Primary strategy : use the `workflow_name` field returned by the GitHub
10
+ * Jobs API (populated when a job is defined in a reusable
11
+ * workflow called via `uses:`).
12
+ * Fallback strategy : parse the job name for a " / " separator pattern that
13
+ * GitHub sometimes uses when nesting reusable calls
14
+ * (e.g. "call-ci-scan / sonarqube" → group "call-ci-scan").
15
+ */
16
+
17
+ import * as fs from 'fs';
18
+ import { WorkflowTestReporter } from './reporter';
19
+ import { GitHubAPI, Jobs } from './github-api';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Interfaces
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface ReusableStepSummary {
26
+ conclusion: string | null;
27
+ passed: boolean;
28
+ }
29
+
30
+ export interface ReusableJobSummary {
31
+ conclusion: string | null;
32
+ passed: boolean;
33
+ steps: { [stepName: string]: ReusableStepSummary };
34
+ }
35
+
36
+ /** Sentinel group key for jobs defined directly in the caller workflow (not via `uses:`). */
37
+ export const DIRECT_JOBS_GROUP = '(caller)';
38
+
39
+ export interface ReusableWorkflowGroup {
40
+ /**
41
+ * Reusable workflow name (from the `workflow_name` API field, e.g. "scan" / "ci-scan"),
42
+ * OR `DIRECT_JOBS_GROUP` for jobs defined directly in the caller workflow.
43
+ */
44
+ workflowName: string;
45
+ /**
46
+ * True when this group holds jobs defined directly in the caller workflow
47
+ * (not triggered via a reusable workflow `uses:` call).
48
+ */
49
+ isDirect: boolean;
50
+ /**
51
+ * The caller-workflow job names that invoked this reusable workflow
52
+ * (e.g. `["call-ci-scan"]`). Empty for direct-job groups.
53
+ */
54
+ callerJobNames: string[];
55
+ jobs: { [jobName: string]: ReusableJobSummary };
56
+ overallResult: 'success' | 'failure' | 'partial_failure' | 'skipped' | 'unknown';
57
+ totalJobs: number;
58
+ passedJobs: number;
59
+ failedJobs: number;
60
+ totalSteps: number;
61
+ passedSteps: number;
62
+ failedSteps: number;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // ReusableWorkflowReporter
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export class ReusableWorkflowReporter extends WorkflowTestReporter {
70
+ private callerWorkflowName: string;
71
+ private reusableGroups: { [wfName: string]: ReusableWorkflowGroup } = {};
72
+
73
+ constructor(workflowName: string) {
74
+ super(workflowName);
75
+ this.callerWorkflowName = workflowName;
76
+ }
77
+
78
+ // -------------------------------------------------------------------------
79
+ // Loading & grouping
80
+ // -------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Convenience method: loads all jobs/steps via the parent's
84
+ * `loadAllJobsAndSteps`, then groups them by reusable workflow name.
85
+ */
86
+ async loadAndGroup(api: GitHubAPI, runId: number): Promise<void> {
87
+ this.setRunId(runId);
88
+ await this.loadAllJobsAndSteps(api);
89
+ await this.groupByReusableWorkflow(api, runId);
90
+ }
91
+
92
+ /**
93
+ * Fetch all jobs for `runId`, then for each job:
94
+ * 1. Determine which reusable workflow group it belongs to
95
+ * (via `workflow_name` API field, falling back to the job-name pattern).
96
+ * 2. Fetch its steps and record pass/fail per step.
97
+ * Builds the internal `reusableGroups` map and computes per-group totals.
98
+ */
99
+ async groupByReusableWorkflow(api: GitHubAPI, runId: number): Promise<void> {
100
+ const jobs: Jobs = await api.getJobs(runId);
101
+ this.reusableGroups = {};
102
+
103
+ for (const [jobName, jobInfo] of Object.entries(jobs)) {
104
+ // A job is from a reusable workflow call when its name contains " / "
105
+ // (GitHub names reusable-job as "<caller-job> / <reusable-job>").
106
+ const slashIdx = jobName.lastIndexOf(' / ');
107
+ const isReusable = slashIdx !== -1;
108
+ const callerJobName = isReusable ? jobName.substring(0, slashIdx) : '';
109
+
110
+ // Determine the group key:
111
+ // • Reusable jobs → normalised workflow_name (e.g. "scan") or caller-job prefix
112
+ // • Direct jobs → DIRECT_JOBS_GROUP sentinel
113
+ let groupKey: string;
114
+ if (!isReusable) {
115
+ groupKey = DIRECT_JOBS_GROUP;
116
+ } else if (jobInfo.workflowName) {
117
+ groupKey = this.normaliseWorkflowName(jobInfo.workflowName);
118
+ } else {
119
+ groupKey = callerJobName; // fallback: use the caller job name as group label
120
+ }
121
+
122
+ // Ensure the group exists
123
+ if (!this.reusableGroups[groupKey]) {
124
+ this.reusableGroups[groupKey] = {
125
+ workflowName: groupKey,
126
+ isDirect: groupKey === DIRECT_JOBS_GROUP,
127
+ callerJobNames: [],
128
+ jobs: {},
129
+ overallResult: 'unknown',
130
+ totalJobs: 0,
131
+ passedJobs: 0,
132
+ failedJobs: 0,
133
+ totalSteps: 0,
134
+ passedSteps: 0,
135
+ failedSteps: 0,
136
+ };
137
+ }
138
+
139
+ // Track which caller jobs invoke this reusable group (dedup)
140
+ if (isReusable && callerJobName &&
141
+ !this.reusableGroups[groupKey].callerJobNames.includes(callerJobName)) {
142
+ this.reusableGroups[groupKey].callerJobNames.push(callerJobName);
143
+ }
144
+
145
+ const group = this.reusableGroups[groupKey];
146
+
147
+ // Fetch steps for this job
148
+ const stepsMap: { [stepName: string]: ReusableStepSummary } = {};
149
+ try {
150
+ const steps = await api.getSteps(runId, jobName);
151
+ for (const [stepName, stepInfo] of Object.entries(steps)) {
152
+ const passed =
153
+ stepInfo.conclusion === 'success' || stepInfo.conclusion === 'skipped';
154
+ stepsMap[stepName] = { conclusion: stepInfo.conclusion, passed };
155
+ group.totalSteps++;
156
+ if (passed) group.passedSteps++;
157
+ else group.failedSteps++;
158
+ }
159
+ } catch {
160
+ // Steps unavailable for this job — continue without them
161
+ }
162
+
163
+ const jobPassed =
164
+ jobInfo.conclusion === 'success' || jobInfo.conclusion === 'skipped';
165
+
166
+ group.jobs[jobName] = {
167
+ conclusion: jobInfo.conclusion,
168
+ passed: jobPassed,
169
+ steps: stepsMap,
170
+ };
171
+ group.totalJobs++;
172
+ if (jobPassed) group.passedJobs++;
173
+ else group.failedJobs++;
174
+ }
175
+
176
+ // Compute per-group overall result
177
+ for (const group of Object.values(this.reusableGroups)) {
178
+ if (group.totalJobs === 0) {
179
+ group.overallResult = 'unknown';
180
+ } else if (group.failedJobs === 0) {
181
+ group.overallResult = 'success';
182
+ } else if (group.failedJobs === group.totalJobs) {
183
+ group.overallResult = 'failure';
184
+ } else {
185
+ group.overallResult = 'partial_failure';
186
+ }
187
+ }
188
+ }
189
+
190
+ // -------------------------------------------------------------------------
191
+ // Helpers
192
+ // -------------------------------------------------------------------------
193
+
194
+ /** Return all computed groups (read-only snapshot). */
195
+ getReusableGroups(): Readonly<{ [wfName: string]: ReusableWorkflowGroup }> {
196
+ return this.reusableGroups;
197
+ }
198
+
199
+ // -------------------------------------------------------------------------
200
+ // Built-in assertions
201
+ // These keep all enumeration logic inside the reporter so tests stay thin.
202
+ // -------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Assert every reusable-workflow group (excludes the `(caller)` direct-job
206
+ * group) has `overallResult === 'success'`.
207
+ * Throws with a summary of every failing group.
208
+ */
209
+ assertAllGroupsSucceed(): void {
210
+ const failures: string[] = [];
211
+ for (const [name, group] of Object.entries(this.reusableGroups)) {
212
+ if (group.isDirect) continue; // caller jobs are checked separately
213
+ if (group.overallResult !== 'success') {
214
+ const failedJobs = Object.entries(group.jobs)
215
+ .filter(([, j]) => !j.passed)
216
+ .map(([jName, j]) => ` job "${jName}": ${j.conclusion}`)
217
+ .join('\n');
218
+ failures.push(`group "${name}" → ${group.overallResult}\n${failedJobs}`);
219
+ }
220
+ }
221
+ if (failures.length) throw new Error(`Reusable groups failed:\n${failures.join('\n')}`);
222
+ }
223
+
224
+ /**
225
+ * Assert every direct (caller-defined) job succeeded.
226
+ * Throws with the list of failed jobs.
227
+ */
228
+ assertAllDirectJobsSucceed(): void {
229
+ const direct = this.reusableGroups[DIRECT_JOBS_GROUP];
230
+ if (!direct) return; // no direct jobs — nothing to assert
231
+ const failures = Object.entries(direct.jobs)
232
+ .filter(([, j]) => !j.passed)
233
+ .map(([name, j]) => ` job "${name}": ${j.conclusion}`);
234
+ if (failures.length)
235
+ throw new Error(`Direct caller jobs failed:\n${failures.join('\n')}`);
236
+ }
237
+
238
+ /**
239
+ * Assert every step in every reusable group has an acceptable conclusion
240
+ * (`'success'` or `'skipped'`).
241
+ * Throws with a full list of offending steps.
242
+ */
243
+ assertAllStepsAcceptable(): void {
244
+ const acceptable = new Set(['success', 'skipped']);
245
+ const failures: string[] = [];
246
+ for (const [groupName, group] of Object.entries(this.reusableGroups)) {
247
+ for (const [jobName, job] of Object.entries(group.jobs)) {
248
+ const displayJob = jobName.includes(' / ')
249
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
250
+ : jobName;
251
+ for (const [stepName, step] of Object.entries(job.steps)) {
252
+ if (!acceptable.has(step.conclusion ?? '')) {
253
+ failures.push(
254
+ `[${groupName}] ${displayJob} → "${stepName}": ${step.conclusion}`
255
+ );
256
+ }
257
+ }
258
+ }
259
+ }
260
+ if (failures.length)
261
+ throw new Error(`Steps with unacceptable conclusions:\n${failures.join('\n')}`);
262
+ }
263
+
264
+ /**
265
+ * Assert that a reusable-workflow group with the given name (case-insensitive
266
+ * substring match) exists and succeeded.
267
+ *
268
+ * @param pattern Substring or regex to match against group names
269
+ * (e.g. `'sonar'`, `'snyk'`, `/ci-scan/i`).
270
+ */
271
+ assertGroupSucceeds(pattern: string | RegExp): void {
272
+ const re = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
273
+ const match = Object.values(this.reusableGroups).find(
274
+ (g) => !g.isDirect && re.test(g.workflowName)
275
+ );
276
+ if (!match)
277
+ throw new Error(
278
+ `No reusable-workflow group matching ${re} found. ` +
279
+ `Available: ${Object.keys(this.reusableGroups).filter((k) => k !== DIRECT_JOBS_GROUP).join(', ')}`
280
+ );
281
+ if (match.overallResult !== 'success') {
282
+ const failedJobs = Object.entries(match.jobs)
283
+ .filter(([, j]) => !j.passed)
284
+ .map(([n, j]) => ` job "${n}": ${j.conclusion}`)
285
+ .join('\n');
286
+ throw new Error(
287
+ `Group "${match.workflowName}" did not succeed (${match.overallResult}):\n${failedJobs}`
288
+ );
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Strip the `.yml` / `.yaml` suffix and owner prefix so the group key is
294
+ * a short, readable workflow name (e.g. "ci-scan" not "ci-scan.yml").
295
+ */
296
+ private normaliseWorkflowName(raw: string): string {
297
+ // Remove trailing .yml / .yaml
298
+ return raw.replace(/\.ya?ml$/, '');
299
+ }
300
+
301
+ /**
302
+ * Fallback: if the job name contains " / ", treat everything before the
303
+ * last " / " as the group (the caller job name).
304
+ * Example: "call-ci-scan / sonarqube" → "call-ci-scan"
305
+ * If no separator, the job itself is its own group.
306
+ */
307
+ private inferGroupFromJobName(jobName: string): string {
308
+ // Use the FIRST segment — convention is "<reusable-wf-name> / <job> / ..."
309
+ const idx = jobName.indexOf(' / ');
310
+ return idx !== -1 ? jobName.substring(0, idx) : jobName;
311
+ }
312
+
313
+ // -------------------------------------------------------------------------
314
+ // Report generation
315
+ // -------------------------------------------------------------------------
316
+
317
+ /** Build a markdown string grouped by reusable workflow. */
318
+ generateGroupedMarkdown(): string {
319
+ const lines: string[] = [];
320
+
321
+ lines.push('# 🔄 Reusable Workflow Aggregated Test Report');
322
+ lines.push('');
323
+ lines.push(`**Caller workflow:** \`${this.callerWorkflowName}\``);
324
+ lines.push('');
325
+
326
+ // Separate direct-caller groups from reusable-WF groups for the overview
327
+ const directGroup = this.reusableGroups[DIRECT_JOBS_GROUP];
328
+ const reusableEntries = Object.entries(this.reusableGroups).filter(
329
+ ([k]) => k !== DIRECT_JOBS_GROUP
330
+ );
331
+
332
+ // ── Top-level overview table (reusable WFs only) ───────────────────────
333
+ lines.push('## 📊 Reusable Workflow Groups');
334
+ lines.push('');
335
+ lines.push(
336
+ '| Reusable Workflow | Triggered by | Result | Jobs (✅/❌/total) | Steps (✅/❌/total) |'
337
+ );
338
+ lines.push(
339
+ '|-------------------|--------------|--------|-------------------|---------------------|'
340
+ );
341
+
342
+ for (const [, group] of reusableEntries) {
343
+ const icon = this.resultIcon(group.overallResult);
344
+ const callerLabel = group.callerJobNames.length
345
+ ? group.callerJobNames.map((n) => `\`${n}\``).join(', ')
346
+ : '—';
347
+ lines.push(
348
+ `| \`${group.workflowName}\` | ${callerLabel} | ${icon} ${group.overallResult} ` +
349
+ `| ${group.passedJobs} / ${group.failedJobs} / ${group.totalJobs} ` +
350
+ `| ${group.passedSteps} / ${group.failedSteps} / ${group.totalSteps} |`
351
+ );
352
+ }
353
+ lines.push('');
354
+
355
+ // ── Direct caller jobs summary (if any) ───────────────────────────────
356
+ if (directGroup) {
357
+ const icon = this.resultIcon(directGroup.overallResult);
358
+ lines.push('## 🔧 Direct Caller Jobs');
359
+ lines.push('');
360
+ lines.push(
361
+ `> Jobs defined directly in the caller workflow (not via a reusable \`uses:\` call). ` +
362
+ `Overall: ${icon} **${directGroup.overallResult}**`
363
+ );
364
+ lines.push('');
365
+ lines.push('| Job | Conclusion | Steps ✅ | Steps ❌ |');
366
+ lines.push('|-----|-----------|---------|---------|');
367
+ for (const [jobName, job] of Object.entries(directGroup.jobs)) {
368
+ const jobIcon = job.passed ? '✅' : '❌';
369
+ const sp = Object.values(job.steps).filter((s) => s.passed).length;
370
+ const sf = Object.values(job.steps).filter((s) => !s.passed).length;
371
+ lines.push(`| ${jobIcon} \`${jobName}\` | ${job.conclusion ?? 'unknown'} | ${sp} | ${sf} |`);
372
+ }
373
+ lines.push('');
374
+ }
375
+
376
+ // ── Per-reusable-group detail ──────────────────────────────────────────
377
+ if (reusableEntries.length > 0) {
378
+ lines.push('## 📋 Per-Reusable-Workflow Detail');
379
+ lines.push('');
380
+ }
381
+
382
+ for (const [, group] of reusableEntries) {
383
+ const icon = this.resultIcon(group.overallResult);
384
+ const callerLabel = group.callerJobNames.length
385
+ ? ` — called via ${group.callerJobNames.map((n) => `\`${n}\``).join(', ')}`
386
+ : '';
387
+ lines.push(`### ${icon} \`${group.workflowName}\`${callerLabel}`);
388
+ lines.push('');
389
+
390
+ lines.push('| Job | Conclusion | Steps ✅ | Steps ❌ |');
391
+ lines.push('|-----|-----------|---------|---------|');
392
+ for (const [jobName, job] of Object.entries(group.jobs)) {
393
+ const jobIcon = job.passed ? '✅' : '❌';
394
+ // Strip the caller-job prefix from display (it's already in callerLabel)
395
+ const displayName = jobName.includes(' / ')
396
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
397
+ : jobName;
398
+ const stepsPassed = Object.values(job.steps).filter((s) => s.passed).length;
399
+ const stepsFailed = Object.values(job.steps).filter((s) => !s.passed).length;
400
+ lines.push(
401
+ `| ${jobIcon} \`${displayName}\` | ${job.conclusion ?? 'unknown'} ` +
402
+ `| ${stepsPassed} | ${stepsFailed} |`
403
+ );
404
+ }
405
+ lines.push('');
406
+
407
+ for (const [jobName, job] of Object.entries(group.jobs)) {
408
+ if (Object.keys(job.steps).length === 0) continue;
409
+ const displayName = jobName.includes(' / ')
410
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
411
+ : jobName;
412
+ lines.push(`<details><summary>Steps — <code>${displayName}</code></summary>`);
413
+ lines.push('');
414
+ lines.push('| Step | Conclusion |');
415
+ lines.push('|------|-----------|');
416
+ for (const [stepName, step] of Object.entries(job.steps)) {
417
+ const stepIcon = step.passed ? '✅' : '❌';
418
+ lines.push(`| ${stepIcon} ${stepName} | ${step.conclusion ?? 'unknown'} |`);
419
+ }
420
+ lines.push('');
421
+ lines.push('</details>');
422
+ lines.push('');
423
+ }
424
+ }
425
+
426
+ return lines.join('\n');
427
+ }
428
+
429
+ /**
430
+ * Write the grouped report to `GITHUB_STEP_SUMMARY` (if running in CI)
431
+ * and always print to console.
432
+ */
433
+ writeGroupedSummaryToGithub(): void {
434
+ const markdown = this.generateGroupedMarkdown();
435
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY;
436
+
437
+ if (summaryFile) {
438
+ try {
439
+ fs.appendFileSync(summaryFile, markdown);
440
+ console.log(
441
+ '✅ Grouped reusable-workflow report written to GitHub Actions summary'
442
+ );
443
+ } catch (err) {
444
+ console.warn(`⚠️ Could not write to GITHUB_STEP_SUMMARY: ${err}`);
445
+ }
446
+ } else {
447
+ console.log(
448
+ '⚠️ GITHUB_STEP_SUMMARY not set — report printed to console only'
449
+ );
450
+ }
451
+
452
+ console.log('\n' + '='.repeat(80));
453
+ console.log(markdown);
454
+ console.log('='.repeat(80) + '\n');
455
+ }
456
+
457
+ // -------------------------------------------------------------------------
458
+ // Internals
459
+ // -------------------------------------------------------------------------
460
+
461
+ private resultIcon(result: ReusableWorkflowGroup['overallResult']): string {
462
+ switch (result) {
463
+ case 'success':
464
+ return '✅';
465
+ case 'failure':
466
+ return '❌';
467
+ case 'partial_failure':
468
+ return '⚠️';
469
+ case 'skipped':
470
+ return '⏭️';
471
+ default:
472
+ return '❓';
473
+ }
474
+ }
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Module-level singleton helpers (mirrors the pattern in reporter.ts)
479
+ // ---------------------------------------------------------------------------
480
+
481
+ let globalReusableReporter: ReusableWorkflowReporter | null = null;
482
+
483
+ /**
484
+ * Get (or lazily create) the module-level `ReusableWorkflowReporter` singleton.
485
+ * Pass `workflowName` on the first call to initialise it.
486
+ */
487
+ export function getReusableReporter(
488
+ workflowName?: string
489
+ ): ReusableWorkflowReporter {
490
+ if (!globalReusableReporter) {
491
+ if (!workflowName) {
492
+ throw new Error(
493
+ 'ReusableWorkflowReporter not initialised — call getReusableReporter(workflowName) first.'
494
+ );
495
+ }
496
+ globalReusableReporter = new ReusableWorkflowReporter(workflowName);
497
+ }
498
+ return globalReusableReporter;
499
+ }
500
+
501
+ /** Reset the singleton (useful between Jest test suites). */
502
+ export function resetReusableReporter(): void {
503
+ globalReusableReporter = null;
504
+ }
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Multi-workflow support
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /**
511
+ * Per-caller-workflow result produced by MultiWorkflowReporter.
512
+ * Each entry holds the run ID, the reusable-workflow groups, and a rolled-up
513
+ * overall result for that caller.
514
+ */
515
+ export interface CallerWorkflowResult {
516
+ callerWorkflowName: string;
517
+ runId: number;
518
+ groups: Readonly<{ [wfName: string]: ReusableWorkflowGroup }>;
519
+ /** Rolled-up result across all reusable groups for this caller workflow. */
520
+ overallResult: 'success' | 'failure' | 'partial_failure' | 'unknown';
521
+ }
522
+
523
+ /**
524
+ * MultiWorkflowReporter
525
+ *
526
+ * Aggregates reports across N caller workflows in one go.
527
+ *
528
+ * Usage:
529
+ * ```ts
530
+ * const reporter = new MultiWorkflowReporter([
531
+ * 'Test - ci-scan',
532
+ * 'Test - ci-db-pr',
533
+ * 'Test - scan-snyk',
534
+ * ]);
535
+ * await reporter.loadAll(api); // resolves latest run for each & groups
536
+ * reporter.writeToGithubSummary(); // write aggregated Markdown to CI summary
537
+ * const results = reporter.getResults(); // access per-workflow data in tests
538
+ * ```
539
+ */
540
+ export class MultiWorkflowReporter {
541
+ private workflowNames: string[];
542
+ private results: { [callerName: string]: CallerWorkflowResult } = {};
543
+
544
+ constructor(workflowNames: string[]) {
545
+ if (workflowNames.length === 0) {
546
+ throw new Error('MultiWorkflowReporter requires at least one workflow name.');
547
+ }
548
+ this.workflowNames = [...workflowNames];
549
+ }
550
+
551
+ // -------------------------------------------------------------------------
552
+ // Loading
553
+ // -------------------------------------------------------------------------
554
+
555
+ /**
556
+ * For every workflow name:
557
+ * 1. Resolve the latest run ID via `api.getLatestRun`.
558
+ * 2. Create a `ReusableWorkflowReporter` instance and call `loadAndGroup`.
559
+ * 3. Store the groups and compute a rolled-up result.
560
+ *
561
+ * Runs sequentially to stay within GitHub API rate limits.
562
+ */
563
+ async loadAll(api: GitHubAPI): Promise<void> {
564
+ for (const wfName of this.workflowNames) {
565
+ const inner = new ReusableWorkflowReporter(wfName);
566
+ const runId = await api.getLatestRun(wfName);
567
+ await inner.loadAndGroup(api, runId);
568
+
569
+ const groups = inner.getReusableGroups();
570
+ this.results[wfName] = {
571
+ callerWorkflowName: wfName,
572
+ runId,
573
+ groups,
574
+ overallResult: this.computeCallerResult(groups),
575
+ };
576
+ }
577
+ }
578
+
579
+ // -------------------------------------------------------------------------
580
+ // Accessors
581
+ // -------------------------------------------------------------------------
582
+
583
+ /** All loaded results keyed by caller workflow name. */
584
+ getResults(): Readonly<{ [callerName: string]: CallerWorkflowResult }> {
585
+ return this.results;
586
+ }
587
+
588
+ /** Convenience: result for a single named caller workflow. */
589
+ getResult(workflowName: string): CallerWorkflowResult | undefined {
590
+ return this.results[workflowName];
591
+ }
592
+
593
+ // -------------------------------------------------------------------------
594
+ // Built-in assertions (delegate to each caller's ReusableWorkflowReporter)
595
+ // -------------------------------------------------------------------------
596
+
597
+ /**
598
+ * Assert that every reusable-workflow group across ALL caller workflows
599
+ * succeeded. Throws with a full cross-workflow summary on any failure.
600
+ */
601
+ assertAllCallerGroupsSucceed(): void {
602
+ const failures: string[] = [];
603
+ for (const [callerName, result] of Object.entries(this.results)) {
604
+ for (const [groupName, group] of Object.entries(result.groups)) {
605
+ if (group.isDirect) continue;
606
+ if (group.overallResult !== 'success') {
607
+ const failedJobs = Object.entries(group.jobs)
608
+ .filter(([, j]) => !j.passed)
609
+ .map(([n, j]) => ` job "${n}": ${j.conclusion}`)
610
+ .join('\n');
611
+ failures.push(` [${callerName}] group "${groupName}" → ${group.overallResult}\n${failedJobs}`);
612
+ }
613
+ }
614
+ }
615
+ if (failures.length)
616
+ throw new Error(`Reusable groups failed across workflows:\n${failures.join('\n')}`);
617
+ }
618
+
619
+ /**
620
+ * Assert that every direct (caller-defined) job across ALL caller workflows
621
+ * succeeded.
622
+ */
623
+ assertAllCallerDirectJobsSucceed(): void {
624
+ const failures: string[] = [];
625
+ for (const [callerName, result] of Object.entries(this.results)) {
626
+ const direct = result.groups[DIRECT_JOBS_GROUP];
627
+ if (!direct) continue;
628
+ for (const [jobName, job] of Object.entries(direct.jobs)) {
629
+ if (!job.passed)
630
+ failures.push(` [${callerName}] job "${jobName}": ${job.conclusion}`);
631
+ }
632
+ }
633
+ if (failures.length)
634
+ throw new Error(`Direct caller jobs failed:\n${failures.join('\n')}`);
635
+ }
636
+
637
+ /**
638
+ * Assert that every step across ALL caller workflows and ALL reusable groups
639
+ * has an acceptable conclusion (`'success'` or `'skipped'`).
640
+ */
641
+ assertAllCallerStepsAcceptable(): void {
642
+ const acceptable = new Set(['success', 'skipped']);
643
+ const failures: string[] = [];
644
+ for (const [callerName, result] of Object.entries(this.results)) {
645
+ for (const [groupName, group] of Object.entries(result.groups)) {
646
+ for (const [jobName, job] of Object.entries(group.jobs)) {
647
+ const displayJob = jobName.includes(' / ')
648
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
649
+ : jobName;
650
+ for (const [stepName, step] of Object.entries(job.steps)) {
651
+ if (!acceptable.has(step.conclusion ?? ''))
652
+ failures.push(
653
+ ` [${callerName}][${groupName}] ${displayJob} → "${stepName}": ${step.conclusion}`
654
+ );
655
+ }
656
+ }
657
+ }
658
+ }
659
+ if (failures.length)
660
+ throw new Error(`Steps with unacceptable conclusions:\n${failures.join('\n')}`);
661
+ }
662
+
663
+ // -------------------------------------------------------------------------
664
+ // Report generation
665
+ // -------------------------------------------------------------------------
666
+
667
+ generateMarkdown(): string {
668
+ const lines: string[] = [];
669
+
670
+ lines.push('# 🔄 Multi-Workflow Reusable Report');
671
+ lines.push('');
672
+ lines.push(`**Workflows included:** ${this.workflowNames.length}`);
673
+ lines.push('');
674
+
675
+ // ── Cross-workflow summary ──────────────────────────────────────────
676
+ lines.push('## 📊 Cross-Workflow Summary');
677
+ lines.push('');
678
+ lines.push(
679
+ '| Caller Workflow | Run ID | Overall | Reusable Groups | Jobs ✅/❌ | Steps ✅/❌ |'
680
+ );
681
+ lines.push(
682
+ '|-----------------|--------|---------|-----------------|-----------|------------|'
683
+ );
684
+
685
+ for (const [callerName, result] of Object.entries(this.results)) {
686
+ const icon = this.resultIcon(result.overallResult);
687
+ // Only list reusable groups (not the direct-jobs sentinel) in the summary
688
+ const reusableGroupNames = Object.keys(result.groups)
689
+ .filter((k) => k !== DIRECT_JOBS_GROUP)
690
+ .map((n) => `\`${n}\``)
691
+ .join(', ') || '—';
692
+ const { passedJobs, failedJobs, passedSteps, failedSteps } =
693
+ this.aggregateCounts(result.groups);
694
+ lines.push(
695
+ `| ${icon} \`${callerName}\` | ${result.runId} | ${result.overallResult} ` +
696
+ `| ${reusableGroupNames} ` +
697
+ `| ${passedJobs} / ${failedJobs} ` +
698
+ `| ${passedSteps} / ${failedSteps} |`
699
+ );
700
+ }
701
+ lines.push('');
702
+
703
+ // ── Per-caller detail ───────────────────────────────────────────────
704
+ lines.push('## 📋 Per-Caller Detail');
705
+ lines.push('');
706
+
707
+ for (const [callerName, result] of Object.entries(this.results)) {
708
+ const icon = this.resultIcon(result.overallResult);
709
+ lines.push(`### ${icon} \`${callerName}\` (run \`${result.runId}\`)`);
710
+ lines.push('');
711
+
712
+ const directGroup = result.groups[DIRECT_JOBS_GROUP];
713
+ const reusableEntries = Object.entries(result.groups).filter(
714
+ ([k]) => k !== DIRECT_JOBS_GROUP
715
+ );
716
+
717
+ // Reusable-workflow overview table for this caller
718
+ lines.push(
719
+ '| Reusable Workflow | Triggered by | Result | Jobs (✅/❌/total) | Steps (✅/❌/total) |'
720
+ );
721
+ lines.push(
722
+ '|-------------------|--------------|--------|-------------------|---------------------|'
723
+ );
724
+
725
+ for (const [, group] of reusableEntries) {
726
+ const gIcon = this.resultIcon(group.overallResult);
727
+ const callerLabel = group.callerJobNames.length
728
+ ? group.callerJobNames.map((n) => `\`${n}\``).join(', ')
729
+ : '—';
730
+ lines.push(
731
+ `| \`${group.workflowName}\` | ${callerLabel} | ${gIcon} ${group.overallResult} ` +
732
+ `| ${group.passedJobs} / ${group.failedJobs} / ${group.totalJobs} ` +
733
+ `| ${group.passedSteps} / ${group.failedSteps} / ${group.totalSteps} |`
734
+ );
735
+ }
736
+ lines.push('');
737
+
738
+ // Direct caller jobs summary
739
+ if (directGroup) {
740
+ const dIcon = this.resultIcon(directGroup.overallResult);
741
+ lines.push(
742
+ `> 🔧 **Direct caller jobs** (${dIcon} ${directGroup.overallResult}): ` +
743
+ Object.keys(directGroup.jobs).map((n) => `\`${n}\``).join(', ')
744
+ );
745
+ lines.push('');
746
+ }
747
+
748
+ // Per-reusable-group job breakdown with collapsible step details
749
+ for (const [, group] of reusableEntries) {
750
+ const gIcon = this.resultIcon(group.overallResult);
751
+ const callerLabel = group.callerJobNames.length
752
+ ? ` — called via ${group.callerJobNames.map((n) => `\`${n}\``).join(', ')}`
753
+ : '';
754
+ lines.push(`#### ${gIcon} \`${group.workflowName}\`${callerLabel}`);
755
+ lines.push('');
756
+ lines.push('| Job | Conclusion | Steps ✅ | Steps ❌ |');
757
+ lines.push('|-----|-----------|---------|---------|');
758
+
759
+ for (const [jobName, job] of Object.entries(group.jobs)) {
760
+ const jobIcon = job.passed ? '✅' : '❌';
761
+ const sp = Object.values(job.steps).filter((s) => s.passed).length;
762
+ const sf = Object.values(job.steps).filter((s) => !s.passed).length;
763
+ // Strip caller-job prefix from job name display
764
+ const displayName = jobName.includes(' / ')
765
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
766
+ : jobName;
767
+ lines.push(
768
+ `| ${jobIcon} \`${displayName}\` | ${job.conclusion ?? 'unknown'} | ${sp} | ${sf} |`
769
+ );
770
+ }
771
+ lines.push('');
772
+
773
+ for (const [jobName, job] of Object.entries(group.jobs)) {
774
+ if (Object.keys(job.steps).length === 0) continue;
775
+ const displayName = jobName.includes(' / ')
776
+ ? jobName.substring(jobName.lastIndexOf(' / ') + 3)
777
+ : jobName;
778
+ lines.push(`<details><summary>Steps — <code>${displayName}</code></summary>`);
779
+ lines.push('');
780
+ lines.push('| Step | Conclusion |');
781
+ lines.push('|------|-----------|');
782
+ for (const [stepName, step] of Object.entries(job.steps)) {
783
+ const sIcon = step.passed ? '✅' : '❌';
784
+ lines.push(`| ${sIcon} ${stepName} | ${step.conclusion ?? 'unknown'} |`);
785
+ }
786
+ lines.push('');
787
+ lines.push('</details>');
788
+ lines.push('');
789
+ }
790
+ }
791
+ }
792
+
793
+ return lines.join('\n');
794
+ }
795
+
796
+ /** Write the aggregated report to `GITHUB_STEP_SUMMARY` and print to console. */
797
+ writeToGithubSummary(): void {
798
+ const markdown = this.generateMarkdown();
799
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY;
800
+
801
+ if (summaryFile) {
802
+ try {
803
+ fs.appendFileSync(summaryFile, markdown);
804
+ console.log('✅ Multi-workflow report written to GitHub Actions summary');
805
+ } catch (err) {
806
+ console.warn(`⚠️ Could not write to GITHUB_STEP_SUMMARY: ${err}`);
807
+ }
808
+ } else {
809
+ console.log('⚠️ GITHUB_STEP_SUMMARY not set — printing to console only');
810
+ }
811
+
812
+ console.log('\n' + '='.repeat(80));
813
+ console.log(markdown);
814
+ console.log('='.repeat(80) + '\n');
815
+ }
816
+
817
+ // -------------------------------------------------------------------------
818
+ // Internals
819
+ // -------------------------------------------------------------------------
820
+
821
+ private computeCallerResult(
822
+ groups: Readonly<{ [wfName: string]: ReusableWorkflowGroup }>
823
+ ): CallerWorkflowResult['overallResult'] {
824
+ const list = Object.values(groups);
825
+ if (list.length === 0) return 'unknown';
826
+ const allOk = list.every(
827
+ (g) => g.overallResult === 'success' || g.overallResult === 'skipped'
828
+ );
829
+ if (allOk) return 'success';
830
+ const allFailed = list.every((g) => g.overallResult === 'failure');
831
+ if (allFailed) return 'failure';
832
+ return 'partial_failure';
833
+ }
834
+
835
+ private aggregateCounts(groups: Readonly<{ [wfName: string]: ReusableWorkflowGroup }>): {
836
+ totalJobs: number;
837
+ passedJobs: number;
838
+ failedJobs: number;
839
+ totalSteps: number;
840
+ passedSteps: number;
841
+ failedSteps: number;
842
+ } {
843
+ let totalJobs = 0, passedJobs = 0, failedJobs = 0;
844
+ let totalSteps = 0, passedSteps = 0, failedSteps = 0;
845
+ for (const g of Object.values(groups)) {
846
+ totalJobs += g.totalJobs; passedJobs += g.passedJobs; failedJobs += g.failedJobs;
847
+ totalSteps += g.totalSteps; passedSteps += g.passedSteps; failedSteps += g.failedSteps;
848
+ }
849
+ return { totalJobs, passedJobs, failedJobs, totalSteps, passedSteps, failedSteps };
850
+ }
851
+
852
+ private resultIcon(result: 'success' | 'failure' | 'partial_failure' | 'skipped' | 'unknown'): string {
853
+ switch (result) {
854
+ case 'success': return '✅';
855
+ case 'failure': return '❌';
856
+ case 'partial_failure': return '⚠️';
857
+ case 'skipped': return '⏭️';
858
+ default: return '❓';
859
+ }
860
+ }
861
+ }
862
+
863
+ // ---------------------------------------------------------------------------
864
+ // Multi-workflow singleton helpers
865
+ // ---------------------------------------------------------------------------
866
+
867
+ let globalMultiReporter: MultiWorkflowReporter | null = null;
868
+
869
+ /**
870
+ * Get (or lazily create) the module-level `MultiWorkflowReporter` singleton.
871
+ * Pass `workflowNames` on the first call to initialise it.
872
+ */
873
+ export function getMultiWorkflowReporter(
874
+ workflowNames?: string[]
875
+ ): MultiWorkflowReporter {
876
+ if (!globalMultiReporter) {
877
+ if (!workflowNames) {
878
+ throw new Error(
879
+ 'MultiWorkflowReporter not initialised — call getMultiWorkflowReporter(workflowNames) first.'
880
+ );
881
+ }
882
+ globalMultiReporter = new MultiWorkflowReporter(workflowNames);
883
+ }
884
+ return globalMultiReporter;
885
+ }
886
+
887
+ /** Reset the multi-workflow singleton (useful between Jest test suites). */
888
+ export function resetMultiWorkflowReporter(): void {
889
+ globalMultiReporter = null;
890
+ }