Skip to content

Commit 323b706

Browse files
committed
PFM-TASK-6308 refactor: enhance coverage evaluation to support build status checks and add pending status for parallel jobs
1 parent 049389a commit 323b706

File tree

4 files changed

+176
-35
lines changed

4 files changed

+176
-35
lines changed

.github/workflows/fe-code-quality.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ jobs:
5252
- name: Unit Tests
5353
id: unit-tests
5454
continue-on-error: true # Allow the step to complete even if it fails
55-
uses: collaborationFactory/github-actions/.github/actions/run-many@improvement/PFM-TASK-6308-Create-reusable-GHA-for-code-coverage-gate
55+
uses: collaborationFactory/github-actions/.github/actions/run-many@master
5656
with:
5757
target: ${{ matrix.target }}
5858
jobIndex: ${{ matrix.jobIndex }}

tools/scripts/run-many/coverage-evaluator.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('coverage-evaluator', () => {
7272
const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
7373

7474
expect(result).toBe(0);
75-
expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-a');
75+
expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-a (null thresholds)');
7676

7777
// Should write coverage report with skipped project showing individual metrics
7878
expect(fs.writeFileSync).toHaveBeenCalledWith(

tools/scripts/run-many/coverage-evaluator.ts

Lines changed: 145 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface ProjectCoverageResult {
1919
functions: number;
2020
branches: number;
2121
} | null;
22-
status: 'PASSED' | 'FAILED' | 'SKIPPED';
22+
status: 'PASSED' | 'FAILED' | 'SKIPPED' | 'PENDING';
2323
}
2424

2525
/**
@@ -182,10 +182,91 @@ function shouldSkipProject(thresholds: CoverageThreshold | null): boolean {
182182
return thresholds === null || (thresholds && Object.keys(thresholds).length === 0);
183183
}
184184

185+
/**
186+
* Evaluates coverage for build status only (no PR comment generation)
187+
* Used by jobs that shouldn't generate coverage reports but need to fail if coverage is insufficient
188+
*/
189+
export function evaluateCoverageForBuildStatus(projects: string[], thresholds: ThresholdConfig): number {
190+
if (!process.env.COVERAGE_THRESHOLDS) {
191+
core.info('No coverage thresholds defined for build status check, skipping evaluation');
192+
return 0; // No thresholds defined, 0 failures
193+
}
194+
195+
let failedProjectsCount = 0;
196+
197+
core.info(`Evaluating coverage for build status only: ${projects.length} projects: ${projects.join(', ')}`);
198+
199+
for (const project of projects) {
200+
const projectThresholds = getProjectThresholds(project, thresholds);
201+
202+
// Skip projects with null thresholds or explicitly empty thresholds
203+
if (shouldSkipProject(projectThresholds)) {
204+
const reason = projectThresholds === null ? 'null thresholds' : 'no thresholds defined';
205+
core.info(`Coverage evaluation skipped for build status for ${project} (${reason})`);
206+
continue;
207+
}
208+
209+
// Try to find coverage file in various locations
210+
const coveragePath = findCoverageSummaryFile(project);
211+
let summary: CoverageSummary | null = null;
212+
213+
if (coveragePath) {
214+
summary = extractCoverageData(coveragePath, project);
215+
}
216+
217+
// If we didn't find project-specific coverage, try global coverage file
218+
if (!summary) {
219+
summary = tryReadFromGlobalCoverage(project);
220+
}
221+
222+
if (!summary) {
223+
core.warning(`No coverage data found for ${project} - marking as build failure`);
224+
failedProjectsCount++;
225+
continue;
226+
}
227+
228+
// Log the actual coverage data found
229+
core.info(`Build status coverage data for ${project}: lines=${summary.lines.pct}%, statements=${summary.statements.pct}%, functions=${summary.functions.pct}%, branches=${summary.branches.pct}%`);
230+
231+
let projectPassed = true;
232+
const failedMetrics: string[] = [];
233+
234+
// Check each metric if threshold is defined
235+
if (projectThresholds.lines !== undefined && summary.lines.pct < projectThresholds.lines) {
236+
projectPassed = false;
237+
failedMetrics.push(`lines: ${summary.lines.pct.toFixed(2)}% < ${projectThresholds.lines}%`);
238+
}
239+
240+
if (projectThresholds.statements !== undefined && summary.statements.pct < projectThresholds.statements) {
241+
projectPassed = false;
242+
failedMetrics.push(`statements: ${summary.statements.pct.toFixed(2)}% < ${projectThresholds.statements}%`);
243+
}
244+
245+
if (projectThresholds.functions !== undefined && summary.functions.pct < projectThresholds.functions) {
246+
projectPassed = false;
247+
failedMetrics.push(`functions: ${summary.functions.pct.toFixed(2)}% < ${projectThresholds.functions}%`);
248+
}
249+
250+
if (projectThresholds.branches !== undefined && summary.branches.pct < projectThresholds.branches) {
251+
projectPassed = false;
252+
failedMetrics.push(`branches: ${summary.branches.pct.toFixed(2)}% < ${projectThresholds.branches}%`);
253+
}
254+
255+
if (!projectPassed) {
256+
core.error(`Project ${project} failed coverage thresholds for build status: ${failedMetrics.join(', ')}`);
257+
failedProjectsCount++;
258+
} else {
259+
core.info(`Project ${project} passed all coverage thresholds for build status`);
260+
}
261+
}
262+
263+
return failedProjectsCount;
264+
}
265+
185266
/**
186267
* Evaluates coverage for all projects against their thresholds
187268
*/
188-
export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig): number {
269+
export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig, jobProjects?: string[]): number {
189270
if (!process.env.COVERAGE_THRESHOLDS) {
190271
core.info('No coverage thresholds defined, skipping evaluation');
191272
return 0; // No thresholds defined, 0 failures
@@ -195,6 +276,9 @@ export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig
195276
const coverageResults: ProjectCoverageResult[] = [];
196277

197278
core.info(`Evaluating coverage for ${projects.length} projects: ${projects.join(', ')}`);
279+
if (jobProjects) {
280+
core.info(`Projects actually processed by this job: ${jobProjects.join(', ')}`);
281+
}
198282

199283
// Debug: List coverage directory contents
200284
debugCoverageDirectory();
@@ -230,22 +314,27 @@ export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig
230314
}
231315

232316
if (!summary) {
233-
core.warning(`No coverage data found for ${project} in any location`);
234-
235-
// Try to list what files exist for this project specifically
236-
const projectSpecificDir = path.resolve(process.cwd(), `coverage/${project}`);
237-
if (fs.existsSync(projectSpecificDir)) {
238-
const files = fs.readdirSync(projectSpecificDir);
239-
core.info(`Files in coverage/${project}/: ${files.join(', ')}`);
317+
// Check if this project was processed by this job or another job
318+
const processedByThisJob = !jobProjects || jobProjects.includes(project);
319+
320+
if (processedByThisJob) {
321+
core.warning(`No coverage data found for ${project} in any location`);
322+
coverageResults.push({
323+
project,
324+
thresholds: projectThresholds,
325+
actual: null,
326+
status: 'FAILED' // Mark as failed if no coverage report is found for this job's projects
327+
});
328+
failedProjectsCount++;
329+
} else {
330+
core.info(`Project ${project} will be processed by another job - showing as pending`);
331+
coverageResults.push({
332+
project,
333+
thresholds: projectThresholds,
334+
actual: null,
335+
status: 'PENDING'
336+
});
240337
}
241-
242-
coverageResults.push({
243-
project,
244-
thresholds: projectThresholds,
245-
actual: null,
246-
status: 'FAILED' // Mark as failed if no coverage report is found
247-
});
248-
failedProjectsCount++;
249338
continue;
250339
}
251340

@@ -329,6 +418,30 @@ function formatCoverageComment(results: ProjectCoverageResult[], artifactUrl: st
329418

330419
comment += `| ${projectCell} | ${metric} | N/A | N/A | ⏩ SKIPPED |\n`;
331420
});
421+
} else if (result.status === 'PENDING') {
422+
// Show individual metrics for projects being processed by other jobs
423+
const metrics = ['lines', 'statements', 'functions', 'branches'];
424+
let hasAnyThreshold = false;
425+
let firstRow = true;
426+
427+
metrics.forEach((metric) => {
428+
// Skip metrics that don't have a threshold
429+
if (!result.thresholds || !result.thresholds[metric]) return;
430+
431+
hasAnyThreshold = true;
432+
const threshold = result.thresholds[metric];
433+
434+
// Only include project name in the first row for this project
435+
const projectCell = firstRow ? result.project : '';
436+
firstRow = false;
437+
438+
comment += `| ${projectCell} | ${metric} | ${threshold}% | Pending | ⏳ PENDING |\n`;
439+
});
440+
441+
// Fallback if no specific thresholds are defined
442+
if (!hasAnyThreshold) {
443+
comment += `| ${result.project} | All | Defined | Pending | ⏳ PENDING |\n`;
444+
}
332445
} else if (result.actual === null) {
333446
// Show individual thresholds when coverage data is missing
334447
const metrics = ['lines', 'statements', 'functions', 'branches'];
@@ -375,18 +488,27 @@ function formatCoverageComment(results: ProjectCoverageResult[], artifactUrl: st
375488
}
376489
});
377490

378-
// Add overall status with failed project count
379-
const overallStatus = failedProjectsCount === 0 ? '✅ PASSED' :
380-
failedProjectsCount === 1 ? '⚠️ WARNING (1 project failing)' :
381-
`❌ FAILED (${failedProjectsCount} projects failing)`;
491+
// Add overall status with failed project count (excluding pending projects)
492+
const actualFailedCount = results.filter(r => r.status === 'FAILED').length;
493+
const overallStatus = actualFailedCount === 0 ? '✅ PASSED' :
494+
actualFailedCount === 1 ? '⚠️ WARNING (1 project failing)' :
495+
`❌ FAILED (${actualFailedCount} projects failing)`;
382496
comment += `\n### Overall Status: ${overallStatus}\n`;
383497

384-
if (failedProjectsCount === 1) {
498+
if (actualFailedCount === 1) {
385499
comment += '\n> Note: The build will continue, but this project should be fixed before merging.\n';
386-
} else if (failedProjectsCount > 1) {
500+
} else if (actualFailedCount > 1) {
387501
comment += '\n> Note: Multiple projects fail coverage thresholds. This PR will be blocked until fixed.\n';
388502
}
389503

504+
// Add explanation of status symbols
505+
comment += '\n---\n';
506+
comment += '**Status Legend:**\n';
507+
comment += '- ✅ **PASSED**: Coverage meets or exceeds threshold\n';
508+
comment += '- ❌ **FAILED**: Coverage is below threshold\n';
509+
comment += '- ⏩ **SKIPPED**: Project excluded from coverage requirements\n';
510+
comment += '- ⏳ **PENDING**: Project is being processed by another parallel job\n';
511+
390512
// Add link to detailed HTML reports
391513
if (artifactUrl) {
392514
comment += `\n📊 [View Detailed HTML Coverage Reports](${artifactUrl})\n`;

tools/scripts/run-many/run-many.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as core from '@actions/core';
44
import * as fs from 'fs';
55
import * as path from 'path';
66
import { getCoverageThresholds } from './threshold-handler';
7-
import { evaluateCoverage, generateEmptyCoverageReport, generateTestFailureReport, generatePlaceholderCoverageReport } from './coverage-evaluator';
7+
import { evaluateCoverage, evaluateCoverageForBuildStatus, generateEmptyCoverageReport, generateTestFailureReport, generatePlaceholderCoverageReport } from './coverage-evaluator';
88
import { Utils } from '../artifacts/utils';
99

1010
function getE2ECommand(command: string, base: string): string {
@@ -115,15 +115,34 @@ function main() {
115115
core.info(JSON.stringify(thresholds, null, 2));
116116

117117
if (commandSucceeded) {
118-
// Command succeeded, evaluate actual coverage
119-
const failedProjectsCount = evaluateCoverage(projects, thresholds);
120-
121-
if (failedProjectsCount > 1) {
122-
core.setFailed(`Multiple projects (${failedProjectsCount}) failed to meet coverage thresholds`);
123-
// Don't exit immediately - we set the failed status but continue running
124-
} else if (failedProjectsCount === 1) {
125-
core.warning('One project failed to meet coverage thresholds - this should be fixed before merging');
126-
// Continue running, with a warning
118+
if (isFirstJob) {
119+
// First job: Generate comprehensive coverage report for ALL affected projects (including skipped ones)
120+
// but only use build failure logic for projects this job actually processed
121+
const allAffectedProjects = Utils.getAllProjects(true, base, target);
122+
core.info(`First job: Generating comprehensive coverage report for all ${allAffectedProjects.length} affected projects: ${allAffectedProjects.join(', ')}`);
123+
core.info(`First job: Actually processed ${projects.length} projects: ${projects.join(', ')}`);
124+
125+
// Generate comprehensive report (this will show skipped projects and projects processed by other jobs)
126+
evaluateCoverage(allAffectedProjects, thresholds, projects);
127+
128+
// But only fail the build based on projects this job actually processed
129+
const processedFailures = evaluateCoverageForBuildStatus(projects, thresholds);
130+
131+
if (processedFailures > 1) {
132+
core.setFailed(`Multiple projects (${processedFailures}) in this job failed to meet coverage thresholds`);
133+
} else if (processedFailures === 1) {
134+
core.warning('One project in this job failed to meet coverage thresholds - this should be fixed before merging');
135+
}
136+
} else {
137+
// Other jobs: Only evaluate their assigned projects for build status
138+
core.info(`Job ${jobIndex}: Evaluating coverage for ${projects.length} assigned projects: ${projects.join(', ')}`);
139+
const failedProjectsCount = evaluateCoverage(projects, thresholds);
140+
141+
if (failedProjectsCount > 1) {
142+
core.setFailed(`Multiple projects (${failedProjectsCount}) failed to meet coverage thresholds`);
143+
} else if (failedProjectsCount === 1) {
144+
core.warning('One project failed to meet coverage thresholds - this should be fixed before merging');
145+
}
127146
}
128147
} else {
129148
// Command failed, generate a failure report for first job only

0 commit comments

Comments
 (0)