From 4e27b663f3a2d20786a5b8bb844d3c6e296f8171 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:07:23 +0000 Subject: [PATCH] feat(#3474): add code-coverage module for scorecard Create a new scorecard-backend-module-code-coverage that integrates with the Backstage Community code-coverage plugin to provide 8 metrics: line/branch percentage, available, covered, and missed counts. The module fetches data from the code-coverage-backend API using the Backstage discovery service and maps the aggregate line and branch fields to individual MetricProviders. Entities are filtered by the backstage.io/code-coverage annotation. Percentage metrics include default thresholds (>80 success, 50-80 warning, <50 error). Includes: - CodeCoverageClient for API communication - Type definitions for the code-coverage API response - 8 MetricProvider implementations via factory pattern - Unit tests for client, providers, and factory (28 tests) - Example entity with code-coverage annotation - Backend app integration Closes #3474 --- .../examples/all-scorecards-location.yaml | 1 + .../code-coverage-scorecard-only.yaml | 12 + .../scorecard/packages/backend/package.json | 1 + .../scorecard/packages/backend/src/index.ts | 5 + .../.eslintrc.js | 1 + .../README.md | 53 +++++ .../package.json | 46 ++++ .../src/clients/CodeCoverageClient.test.ts | 69 ++++++ .../src/clients/CodeCoverageClient.ts | 46 ++++ .../src/clients/types.ts | 45 ++++ .../src/index.ts | 23 ++ .../src/metricProviders/CodeCoverageConfig.ts | 124 ++++++++++ .../CodeCoverageMetricProvider.test.ts | 216 ++++++++++++++++++ .../CodeCoverageMetricProvider.ts | 88 +++++++ .../CodeCoverageMetricProviderFactory.test.ts | 78 +++++++ .../CodeCoverageMetricProviderFactory.ts | 51 +++++ .../src/module.ts | 42 ++++ workspaces/scorecard/yarn.lock | 15 ++ 18 files changed, 916 insertions(+) create mode 100644 workspaces/scorecard/examples/components/code-coverage-scorecard-only.yaml create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/.eslintrc.js create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/package.json create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/types.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/index.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageConfig.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/module.ts diff --git a/workspaces/scorecard/examples/all-scorecards-location.yaml b/workspaces/scorecard/examples/all-scorecards-location.yaml index 2101c94600..106aaa8bb0 100644 --- a/workspaces/scorecard/examples/all-scorecards-location.yaml +++ b/workspaces/scorecard/examples/all-scorecards-location.yaml @@ -6,6 +6,7 @@ metadata: description: A collection of all the scorecards spec: targets: + - ./components/code-coverage-scorecard-only.yaml - ./components/dependabot-scorecard-only.yaml - ./components/github-scorecard-only.yaml - ./components/jira-scorecard-only.yaml diff --git a/workspaces/scorecard/examples/components/code-coverage-scorecard-only.yaml b/workspaces/scorecard/examples/components/code-coverage-scorecard-only.yaml new file mode 100644 index 0000000000..b2d9a5e42f --- /dev/null +++ b/workspaces/scorecard/examples/components/code-coverage-scorecard-only.yaml @@ -0,0 +1,12 @@ +--- +# Component with Code Coverage Scorecard only +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: code-coverage-scorecard-only + annotations: + backstage.io/code-coverage: enabled +spec: + type: service + owner: group:development/guests + lifecycle: experimental diff --git a/workspaces/scorecard/packages/backend/package.json b/workspaces/scorecard/packages/backend/package.json index 2d04f76f2b..6a99a60838 100644 --- a/workspaces/scorecard/packages/backend/package.json +++ b/workspaces/scorecard/packages/backend/package.json @@ -46,6 +46,7 @@ "@backstage/plugin-search-backend-node": "^1.4.2", "@backstage/plugin-techdocs-backend": "^2.1.6", "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^", diff --git a/workspaces/scorecard/packages/backend/src/index.ts b/workspaces/scorecard/packages/backend/src/index.ts index 7a3a24961b..6af894545b 100644 --- a/workspaces/scorecard/packages/backend/src/index.ts +++ b/workspaces/scorecard/packages/backend/src/index.ts @@ -92,4 +92,9 @@ backend.add( '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-sonarqube' ), ); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage' + ), +); backend.start(); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md new file mode 100644 index 0000000000..59f8784077 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/README.md @@ -0,0 +1,53 @@ +# scorecard-backend-module-code-coverage + +The code-coverage backend module for the scorecard plugin. + +This module integrates with the [Backstage Community code-coverage plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/code-coverage) to provide code coverage metrics in the scorecard. + +## Metrics + +This module provides the following metrics: + +| Metric ID | Title | Source | +| --------------------------------- | ------------------------------------- | ----------------------------- | +| `code-coverage.line_percentage` | Code coverage (Lines) | `aggregate.line.percentage` | +| `code-coverage.line_available` | Code coverage - Tracked lines of code | `aggregate.line.available` | +| `code-coverage.line_covered` | Code coverage - Covered lines of code | `aggregate.line.covered` | +| `code-coverage.line_missed` | Code coverage - Missed lines of code | `aggregate.line.missed` | +| `code-coverage.branch_percentage` | Code coverage (Branches) | `aggregate.branch.percentage` | +| `code-coverage.branch_available` | Code coverage - Tracked branches | `aggregate.branch.available` | +| `code-coverage.branch_covered` | Code coverage - Covered branches | `aggregate.branch.covered` | +| `code-coverage.branch_missed` | Code coverage - Missed branches | `aggregate.branch.missed` | + +## Prerequisites + +This module requires the [code-coverage-backend](https://github.com/backstage/community-plugins/tree/main/workspaces/code-coverage/plugins/code-coverage-backend) plugin to be installed and configured in your Backstage instance. + +## Entity annotation + +Entities must have the `backstage.io/code-coverage` annotation to be tracked by this module: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: my-service + annotations: + backstage.io/code-coverage: enabled +spec: + type: service + owner: my-team + lifecycle: production +``` + +## Installation + +Add the module to your backend: + +```ts +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage' + ), +); +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/package.json new file mode 100644 index 0000000000..edc3851f47 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/package.json @@ -0,0 +1,46 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "The code-coverage backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-code-coverage" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.8.0", + "@backstage/catalog-client": "^1.14.0", + "@backstage/catalog-model": "^1.7.7", + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.11.1", + "@backstage/cli": "^0.36.0" + }, + "files": [ + "dist" + ] +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.test.ts new file mode 100644 index 0000000000..10b4c47853 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { CodeCoverageClient } from './CodeCoverageClient'; +import type { CodeCoverageReport } from './types'; + +const mockDiscovery = mockServices.discovery.mock({ + getBaseUrl: async (pluginId: string) => + `http://localhost:7007/api/${pluginId}`, +}); +const mockLogger = mockServices.logger.mock(); + +const sampleReport: CodeCoverageReport = { + aggregate: { + line: { available: 5, covered: 4, missed: 1, percentage: 80 }, + branch: { available: 0, covered: 0, missed: 0, percentage: 0 }, + }, + entity: { kind: 'Component', name: 'entity-name', namespace: 'default' }, + files: [], +}; + +describe('CodeCoverageClient', () => { + let client: CodeCoverageClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new CodeCoverageClient(mockDiscovery, mockLogger); + }); + + it('should call the correct URL and return the report', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => sampleReport, + } as Response); + + const report = await client.getReport('component:default/entity-name'); + + expect(report).toEqual(sampleReport); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:7007/api/code-coverage/report?entity=component%3Adefault%2Fentity-name', + ); + }); + + it('should throw on non-ok response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + } as Response); + + await expect(client.getReport('component:default/missing')).rejects.toThrow( + 'Code coverage API error: 404 Not Found', + ); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.ts new file mode 100644 index 0000000000..9a5f1d7e6e --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/CodeCoverageClient.ts @@ -0,0 +1,46 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import type { CodeCoverageReport } from './types'; + +export class CodeCoverageClient { + private readonly discovery: DiscoveryService; + private readonly logger: LoggerService; + + constructor(discovery: DiscoveryService, logger: LoggerService) { + this.discovery = discovery; + this.logger = logger.child({ component: 'CodeCoverageClient' }); + } + + async getReport(entityRef: string): Promise { + const baseUrl = await this.discovery.getBaseUrl('code-coverage'); + const url = `${baseUrl}/report?entity=${encodeURIComponent(entityRef)}`; + + this.logger.debug(`Fetching code coverage report for entity ${entityRef}`); + + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Code coverage API error: ${response.status} ${response.statusText} for ${url}`, + ); + } + return response.json() as Promise; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/types.ts new file mode 100644 index 0000000000..e454bba742 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/clients/types.ts @@ -0,0 +1,45 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Represents a single coverage aggregate section (line or branch). + */ +export interface CoverageAggregate { + available: number; + covered: number; + missed: number; + percentage: number; +} + +/** + * The response from the code-coverage-backend report API. + */ +export interface CodeCoverageReport { + aggregate: { + line: CoverageAggregate; + branch: CoverageAggregate; + }; + entity: { + kind: string; + name: string; + namespace: string; + }; + files: Array<{ + branchHits: Record; + filename: string; + lineHits: Record; + }>; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/index.ts new file mode 100644 index 0000000000..4e0340bca3 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * The code-coverage backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleCodeCoverage as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageConfig.ts new file mode 100644 index 0000000000..3f77cf0085 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageConfig.ts @@ -0,0 +1,124 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export const CODE_COVERAGE_ANNOTATION = 'backstage.io/code-coverage'; + +export const CODE_COVERAGE_METRICS = [ + 'line_percentage', + 'line_available', + 'line_covered', + 'line_missed', + 'branch_percentage', + 'branch_available', + 'branch_covered', + 'branch_missed', +] as const; + +export type CodeCoverageMetricId = (typeof CODE_COVERAGE_METRICS)[number]; + +export const CODE_COVERAGE_METRIC_CONFIG: Record< + CodeCoverageMetricId, + { id: string; title: string; description: string } +> = { + line_percentage: { + id: 'code-coverage.line_percentage', + title: 'Code coverage (Lines)', + description: 'Percentage of lines covered by tests.', + }, + line_available: { + id: 'code-coverage.line_available', + title: 'Code coverage - Tracked lines of code', + description: 'Total number of lines tracked for code coverage.', + }, + line_covered: { + id: 'code-coverage.line_covered', + title: 'Code coverage - Covered lines of code', + description: 'Number of lines covered by tests.', + }, + line_missed: { + id: 'code-coverage.line_missed', + title: 'Code coverage - Missed lines of code', + description: 'Number of lines not covered by tests.', + }, + branch_percentage: { + id: 'code-coverage.branch_percentage', + title: 'Code coverage (Branches)', + description: 'Percentage of branches covered by tests.', + }, + branch_available: { + id: 'code-coverage.branch_available', + title: 'Code coverage - Tracked branches', + description: 'Total number of branches tracked for code coverage.', + }, + branch_covered: { + id: 'code-coverage.branch_covered', + title: 'Code coverage - Covered branches', + description: 'Number of branches covered by tests.', + }, + branch_missed: { + id: 'code-coverage.branch_missed', + title: 'Code coverage - Missed branches', + description: 'Number of branches not covered by tests.', + }, +}; + +/** + * Maps metric IDs to the path within the code-coverage report aggregate. + */ +export const CODE_COVERAGE_AGGREGATE_KEYS: Record< + CodeCoverageMetricId, + { + section: 'line' | 'branch'; + field: 'percentage' | 'available' | 'covered' | 'missed'; + } +> = { + line_percentage: { section: 'line', field: 'percentage' }, + line_available: { section: 'line', field: 'available' }, + line_covered: { section: 'line', field: 'covered' }, + line_missed: { section: 'line', field: 'missed' }, + branch_percentage: { section: 'branch', field: 'percentage' }, + branch_available: { section: 'branch', field: 'available' }, + branch_covered: { section: 'branch', field: 'covered' }, + branch_missed: { section: 'branch', field: 'missed' }, +}; + +const PERCENTAGE_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '>80' }, + { key: 'warning', expression: '50-80' }, + { key: 'error', expression: '<50' }, + ], +}; + +const COUNT_THRESHOLDS: ThresholdConfig = { + rules: [], +}; + +export const CODE_COVERAGE_THRESHOLDS: Record< + CodeCoverageMetricId, + ThresholdConfig +> = { + line_percentage: PERCENTAGE_THRESHOLDS, + line_available: COUNT_THRESHOLDS, + line_covered: COUNT_THRESHOLDS, + line_missed: COUNT_THRESHOLDS, + branch_percentage: PERCENTAGE_THRESHOLDS, + branch_available: COUNT_THRESHOLDS, + branch_covered: COUNT_THRESHOLDS, + branch_missed: COUNT_THRESHOLDS, +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.test.ts new file mode 100644 index 0000000000..d2cfc08244 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.test.ts @@ -0,0 +1,216 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import type { Entity } from '@backstage/catalog-model'; +import { mockServices } from '@backstage/backend-test-utils'; + +import { CodeCoverageMetricProvider } from './CodeCoverageMetricProvider'; +import { + CODE_COVERAGE_ANNOTATION, + CODE_COVERAGE_METRIC_CONFIG, + CODE_COVERAGE_THRESHOLDS, + type CodeCoverageMetricId, +} from './CodeCoverageConfig'; +import type { CodeCoverageReport } from '../clients/types'; + +jest.mock('../clients/CodeCoverageClient'); + +const mockGetReport = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + const { CodeCoverageClient } = jest.requireMock( + '../clients/CodeCoverageClient', + ); + CodeCoverageClient.mockImplementation(() => ({ + getReport: mockGetReport, + })); +}); + +const mockDiscovery = mockServices.discovery.mock(); +const mockLogger = mockServices.logger.mock(); + +function entity(): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service', + namespace: 'default', + annotations: { [CODE_COVERAGE_ANNOTATION]: 'enabled' }, + }, + } as Entity; +} + +const sampleReport: CodeCoverageReport = { + aggregate: { + line: { available: 5, covered: 4, missed: 1, percentage: 80 }, + branch: { available: 10, covered: 7, missed: 3, percentage: 70 }, + }, + entity: { kind: 'Component', name: 'my-service', namespace: 'default' }, + files: [], +}; + +function createProvider( + metricId: CodeCoverageMetricId, +): CodeCoverageMetricProvider { + const { CodeCoverageClient } = jest.requireMock( + '../clients/CodeCoverageClient', + ); + const client = new CodeCoverageClient(mockDiscovery, mockLogger); + return new CodeCoverageMetricProvider(client, metricId); +} + +describe('CodeCoverageMetricProvider', () => { + describe('getProviderDatasourceId', () => { + it('returns code-coverage', () => { + const provider = createProvider('line_percentage'); + expect(provider.getProviderDatasourceId()).toBe('code-coverage'); + }); + }); + + describe('getProviderId / getMetric', () => { + it.each([ + [ + 'line_percentage', + 'code-coverage.line_percentage', + 'Code coverage (Lines)', + ], + [ + 'line_available', + 'code-coverage.line_available', + 'Code coverage - Tracked lines of code', + ], + [ + 'line_covered', + 'code-coverage.line_covered', + 'Code coverage - Covered lines of code', + ], + [ + 'line_missed', + 'code-coverage.line_missed', + 'Code coverage - Missed lines of code', + ], + [ + 'branch_percentage', + 'code-coverage.branch_percentage', + 'Code coverage (Branches)', + ], + [ + 'branch_available', + 'code-coverage.branch_available', + 'Code coverage - Tracked branches', + ], + [ + 'branch_covered', + 'code-coverage.branch_covered', + 'Code coverage - Covered branches', + ], + [ + 'branch_missed', + 'code-coverage.branch_missed', + 'Code coverage - Missed branches', + ], + ] as const)( + 'for %s returns id %s and title %s', + (metricId, expectedId, expectedTitle) => { + const provider = createProvider(metricId); + expect(provider.getProviderId()).toBe(expectedId); + const metric = provider.getMetric(); + expect(metric.id).toBe(expectedId); + expect(metric.title).toBe(expectedTitle); + expect(metric.description).toBe( + CODE_COVERAGE_METRIC_CONFIG[metricId].description, + ); + expect(metric.type).toBe('number'); + expect(metric.history).toBe(true); + }, + ); + }); + + describe('getMetricType', () => { + it('returns number', () => { + const provider = createProvider('line_percentage'); + expect(provider.getMetricType()).toBe('number'); + }); + }); + + describe('getMetricThresholds', () => { + it('returns percentage thresholds for percentage metrics', () => { + const provider = createProvider('line_percentage'); + expect(provider.getMetricThresholds()).toEqual( + CODE_COVERAGE_THRESHOLDS.line_percentage, + ); + expect(provider.getMetricThresholds().rules.length).toBeGreaterThan(0); + }); + + it('returns empty thresholds for count metrics', () => { + const provider = createProvider('line_available'); + expect(provider.getMetricThresholds()).toEqual( + CODE_COVERAGE_THRESHOLDS.line_available, + ); + expect(provider.getMetricThresholds().rules).toHaveLength(0); + }); + }); + + describe('getCatalogFilter', () => { + it('requires backstage.io/code-coverage annotation', () => { + const provider = createProvider('line_percentage'); + expect(provider.getCatalogFilter()).toEqual({ + [`metadata.annotations.${CODE_COVERAGE_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }); + }); + }); + + describe('calculateMetric', () => { + it.each([ + ['line_percentage', 80], + ['line_available', 5], + ['line_covered', 4], + ['line_missed', 1], + ['branch_percentage', 70], + ['branch_available', 10], + ['branch_covered', 7], + ['branch_missed', 3], + ] as const)( + 'extracts %s from the report and returns %d', + async (metricId, expectedValue) => { + mockGetReport.mockResolvedValue(sampleReport); + const provider = createProvider(metricId); + + const result = await provider.calculateMetric(entity()); + + expect(result).toBe(expectedValue); + expect(mockGetReport).toHaveBeenCalledWith( + 'component:default/my-service', + ); + }, + ); + + it('propagates errors when getReport fails', async () => { + mockGetReport.mockRejectedValueOnce( + new Error('code-coverage unavailable'), + ); + const provider = createProvider('line_percentage'); + + await expect(provider.calculateMetric(entity())).rejects.toThrow( + 'code-coverage unavailable', + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.ts new file mode 100644 index 0000000000..b323e01351 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProvider.ts @@ -0,0 +1,88 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import type { Entity } from '@backstage/catalog-model'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; + +import { CodeCoverageClient } from '../clients/CodeCoverageClient'; +import { + type CodeCoverageMetricId, + CODE_COVERAGE_ANNOTATION, + CODE_COVERAGE_METRIC_CONFIG, + CODE_COVERAGE_AGGREGATE_KEYS, + CODE_COVERAGE_THRESHOLDS, +} from './CodeCoverageConfig'; + +/** + * Metric provider for a single code-coverage metric. + * One instance per metric; the module registers eight providers. + */ +export class CodeCoverageMetricProvider implements MetricProvider<'number'> { + private readonly client: CodeCoverageClient; + private readonly metricId: CodeCoverageMetricId; + + constructor(client: CodeCoverageClient, metricId: CodeCoverageMetricId) { + this.client = client; + this.metricId = metricId; + } + + getProviderDatasourceId(): string { + return 'code-coverage'; + } + + getProviderId(): string { + return CODE_COVERAGE_METRIC_CONFIG[this.metricId].id; + } + + getMetricType(): 'number' { + return 'number'; + } + + getMetric(): Metric<'number'> { + const meta = CODE_COVERAGE_METRIC_CONFIG[this.metricId]; + return { + id: meta.id, + title: meta.title, + description: meta.description, + type: this.getMetricType(), + history: true, + }; + } + + getMetricThresholds(): ThresholdConfig { + return CODE_COVERAGE_THRESHOLDS[this.metricId]; + } + + getCatalogFilter(): Record { + return { + [`metadata.annotations.${CODE_COVERAGE_ANNOTATION}`]: + CATALOG_FILTER_EXISTS, + }; + } + + async calculateMetric(entity: Entity): Promise { + const entityRef = stringifyEntityRef(entity); + const report = await this.client.getReport(entityRef); + const mapping = CODE_COVERAGE_AGGREGATE_KEYS[this.metricId]; + return report.aggregate[mapping.section][mapping.field]; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.test.ts new file mode 100644 index 0000000000..15ca3e4fdd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { + createCodeCoverageMetricProvider, + createCodeCoverageMetricProviders, +} from './CodeCoverageMetricProviderFactory'; + +jest.mock('../clients/CodeCoverageClient'); + +const mockDiscovery = mockServices.discovery.mock(); +const mockLogger = mockServices.logger.mock(); + +describe('createCodeCoverageMetricProvider', () => { + it('returns a provider for line_percentage', () => { + const provider = createCodeCoverageMetricProvider( + mockDiscovery, + mockLogger, + 'line_percentage', + ); + expect(provider.getProviderId()).toBe('code-coverage.line_percentage'); + expect(provider.getProviderDatasourceId()).toBe('code-coverage'); + expect(provider.getMetricType()).toBe('number'); + }); + + it('returns a provider for branch_percentage', () => { + const provider = createCodeCoverageMetricProvider( + mockDiscovery, + mockLogger, + 'branch_percentage', + ); + expect(provider.getProviderId()).toBe('code-coverage.branch_percentage'); + expect(provider.getMetricType()).toBe('number'); + }); +}); + +describe('createCodeCoverageMetricProviders', () => { + it('returns eight providers with correct IDs', () => { + const providers = createCodeCoverageMetricProviders( + mockDiscovery, + mockLogger, + ); + expect(providers).toHaveLength(8); + expect(providers.map(p => p.getProviderId())).toEqual([ + 'code-coverage.line_percentage', + 'code-coverage.line_available', + 'code-coverage.line_covered', + 'code-coverage.line_missed', + 'code-coverage.branch_percentage', + 'code-coverage.branch_available', + 'code-coverage.branch_covered', + 'code-coverage.branch_missed', + ]); + }); + + it('returns all number-type providers', () => { + const providers = createCodeCoverageMetricProviders( + mockDiscovery, + mockLogger, + ); + const types = providers.map(p => p.getMetricType()); + expect(types.every(t => t === 'number')).toBe(true); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.ts new file mode 100644 index 0000000000..c70bd815e9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/metricProviders/CodeCoverageMetricProviderFactory.ts @@ -0,0 +1,51 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { + DiscoveryService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; + +import { CodeCoverageClient } from '../clients/CodeCoverageClient'; +import { CodeCoverageMetricProvider } from './CodeCoverageMetricProvider'; +import { + CODE_COVERAGE_METRICS, + type CodeCoverageMetricId, +} from './CodeCoverageConfig'; + +/** + * Creates a single code-coverage metric provider for the given metric ID. + */ +export function createCodeCoverageMetricProvider( + discovery: DiscoveryService, + logger: LoggerService, + metricId: CodeCoverageMetricId, +): MetricProvider<'number'> { + const client = new CodeCoverageClient(discovery, logger); + return new CodeCoverageMetricProvider(client, metricId); +} + +/** + * Creates one metric provider per code-coverage metric (8 total). + */ +export function createCodeCoverageMetricProviders( + discovery: DiscoveryService, + logger: LoggerService, +): MetricProvider<'number'>[] { + return CODE_COVERAGE_METRICS.map(metricId => + createCodeCoverageMetricProvider(discovery, logger, metricId), + ); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/module.ts new file mode 100644 index 0000000000..666a052c2c --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-code-coverage/src/module.ts @@ -0,0 +1,42 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { createCodeCoverageMetricProviders } from './metricProviders/CodeCoverageMetricProviderFactory'; + +export const scorecardModuleCodeCoverage = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'code-coverage', + register(reg) { + reg.registerInit({ + deps: { + metrics: scorecardMetricsExtensionPoint, + discovery: coreServices.discovery, + logger: coreServices.logger, + }, + + async init({ metrics, discovery, logger }) { + const providers = createCodeCoverageMetricProviders(discovery, logger); + for (const provider of providers) { + metrics.addMetricProvider(provider); + } + }, + }); + }, +}); diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 8f5b9e083f..ee9e4850c2 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -12204,6 +12204,20 @@ __metadata: languageName: node linkType: hard +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage@workspace:plugins/scorecard-backend-module-code-coverage" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.8.0" + "@backstage/backend-test-utils": "npm:^1.11.1" + "@backstage/catalog-client": "npm:^1.14.0" + "@backstage/catalog-model": "npm:^1.7.7" + "@backstage/cli": "npm:^0.36.0" + "@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot@workspace:plugins/scorecard-backend-module-dependabot": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot@workspace:plugins/scorecard-backend-module-dependabot" @@ -18056,6 +18070,7 @@ __metadata: "@backstage/plugin-search-backend-node": "npm:^1.4.2" "@backstage/plugin-techdocs-backend": "npm:^2.1.6" "@red-hat-developer-hub/backstage-plugin-scorecard-backend": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-code-coverage": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^"