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.
- package/README.md +16 -19
- package/dist/check_run.d.ts +20 -0
- package/dist/check_run.d.ts.map +1 -0
- package/dist/check_run.js +110 -0
- package/dist/check_run.js.map +1 -0
- package/dist/github-api.d.ts +2 -0
- package/dist/github-api.d.ts.map +1 -1
- package/dist/github-api.js +37 -11
- package/dist/github-api.js.map +1 -1
- package/dist/global_setup.d.ts +9 -0
- package/dist/global_setup.d.ts.map +1 -0
- package/dist/global_setup.js +50 -0
- package/dist/global_setup.js.map +1 -0
- package/dist/global_teardown.d.ts +10 -0
- package/dist/global_teardown.d.ts.map +1 -0
- package/dist/global_teardown.js +77 -0
- package/dist/global_teardown.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/reporter.d.ts +28 -0
- package/dist/reporter.d.ts.map +1 -1
- package/dist/reporter.js +106 -51
- package/dist/reporter.js.map +1 -1
- package/dist/reusable_reporter.d.ts +211 -0
- package/dist/reusable_reporter.d.ts.map +1 -0
- package/dist/reusable_reporter.js +749 -0
- package/dist/reusable_reporter.js.map +1 -0
- package/dist/workflow_status.d.ts +57 -0
- package/dist/workflow_status.d.ts.map +1 -0
- package/dist/workflow_status.js +165 -0
- package/dist/workflow_status.js.map +1 -0
- package/package.json +6 -7
- package/src/check_run.ts +122 -0
- package/src/github-api.ts +44 -12
- package/src/global_setup.ts +16 -0
- package/src/global_teardown.ts +53 -0
- package/src/index.ts +3 -0
- package/src/reporter.ts +121 -21
- package/src/reusable_reporter.ts +890 -0
- 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
|
+
}
|