diff --git a/workspaces/scorecard/.changeset/bright-candies-roll.md b/workspaces/scorecard/.changeset/bright-candies-roll.md new file mode 100644 index 0000000000..43b17be65d --- /dev/null +++ b/workspaces/scorecard/.changeset/bright-candies-roll.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-backend': minor +'@red-hat-developer-hub/backstage-plugin-scorecard-node': minor +'@red-hat-developer-hub/backstage-plugin-scorecard': minor +--- + +Add support for batch metric providers, allowing a single provider to handle multiple metrics efficiently. Introduce a new backend module for configurable file existence checks (filecheck.\*) that verify whether required files (like README, LICENSE, or CODEOWNERS) are present in a repository. diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 0905992eb4..1541c3612f 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -64,6 +64,24 @@ app: sm: { w: 4, h: 6, x: 4 } xs: { w: 4, h: 6, x: 4 } xxs: { w: 4, h: 6, x: 4 } + AggregatedCardWithGithubFilecheckLicense: + priority: 450 + breakpoints: + xl: { w: 4, h: 6 } + lg: { w: 4, h: 6 } + md: { w: 4, h: 6 } + sm: { w: 4, h: 6 } + xs: { w: 4, h: 6 } + xxs: { w: 4, h: 6 } + AggregatedCardWithGithubFilecheckCodeowners: + priority: 460 + breakpoints: + xl: { w: 4, h: 6 } + lg: { w: 4, h: 6 } + md: { w: 4, h: 6 } + sm: { w: 4, h: 6 } + xs: { w: 4, h: 6 } + xxs: { w: 4, h: 6 } organization: name: My Company @@ -198,6 +216,11 @@ scorecard: type: statusGrouped description: This KPI is provide information about Jira open issues grouped by status. metricId: jira.open_issues + licenseFileExistsKpi: + title: License File Exists KPI + type: statusGrouped + description: This KPI is provide information about whether the license file exists in the repository. + metricId: filecheck.license plugins: jira: open_issues: @@ -211,3 +234,11 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 5 } + filecheck: + files: + license: 'LICENSE' + codeowners: 'CODEOWNERS' + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 5 } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts index 26ed91642c..b501e6301c 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts @@ -42,6 +42,7 @@ import { jiraEntitiesDrillDownResponse, jiraEntitiesDrillDownNoDataResponse, jiraMetricMetadataResponse, + fileCheckScorecardResponse, } from './utils/scorecardResponseUtils'; import { ScorecardMessages, @@ -254,6 +255,71 @@ test.describe('Scorecard Plugin Tests', () => { await runAccessibilityTests(page, testInfo); }); + + test('Verify file check metrics display correctly', async ({ + browser, + }, testInfo) => { + await mockApiResponse( + page, + ScorecardRoutes.SCORECARD_API_ROUTE, + fileCheckScorecardResponse, + ); + + await catalogPage.openCatalog(); + await catalogPage.openComponent('Red Hat Developer Hub'); + await scorecardPage.openTab(); + + const existLabel = translations.thresholds.exist ?? 'Exist'; + const missingLabel = translations.thresholds.missing ?? 'Missing'; + + const readmeTitle = evaluateMessage( + translations.metric.filecheck.title, + 'readme', + ); + const readmeDescription = evaluateMessage( + translations.metric.filecheck.description, + 'readme', + ); + + const readmeCard = page + .locator('[role="article"]') + .filter({ hasText: readmeTitle }) + .first(); + await expect(readmeCard).toBeVisible(); + await expect(readmeCard.getByText(readmeDescription)).toBeVisible(); + await expect( + readmeCard.getByText(existLabel, { exact: true }), + ).toBeVisible(); + await expect( + readmeCard.getByText(missingLabel, { exact: true }), + ).toBeVisible(); + + const codeownersTitle = evaluateMessage( + translations.metric.filecheck.title, + 'codeowners', + ); + const codeownersDescription = evaluateMessage( + translations.metric.filecheck.description, + 'codeowners', + ); + + const codeownersCard = page + .locator('[role="article"]') + .filter({ hasText: codeownersTitle }) + .first(); + await expect(codeownersCard).toBeVisible(); + await expect( + codeownersCard.getByText(codeownersDescription), + ).toBeVisible(); + await expect( + codeownersCard.getByText(existLabel, { exact: true }), + ).toBeVisible(); + await expect( + codeownersCard.getByText(missingLabel, { exact: true }), + ).toBeVisible(); + + await runAccessibilityTests(page, testInfo); + }); }); test.describe('Homepage aggregated scorecards', () => { diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts index c972bfaf92..71a00b2eac 100644 --- a/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts +++ b/workspaces/scorecard/packages/app-legacy/e2e-tests/utils/scorecardResponseUtils.ts @@ -506,3 +506,74 @@ export const jiraEntitiesDrillDownNoDataResponse = { isCapped: false, }, }; + +export const fileCheckScorecardResponse = [ + { + id: 'filecheck.readme', + status: 'success', + metadata: { + title: 'GitHub File: README.md', + description: 'Checks if README.md exists in the repository.', + type: 'boolean', + history: true, + }, + result: { + value: true, + timestamp: '2025-09-08T09:08:55.629Z', + thresholdResult: { + definition: { + rules: [ + { + key: 'exist', + expression: '==true', + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: 'error.main', + icon: 'scorecardErrorStatusIcon', + }, + ], + }, + status: 'success', + evaluation: 'exist', + }, + }, + }, + { + id: 'filecheck.codeowners', + status: 'success', + metadata: { + title: 'GitHub File: CODEOWNERS', + description: 'Checks if CODEOWNERS exists in the repository.', + type: 'boolean', + history: true, + }, + result: { + value: false, + timestamp: '2025-09-08T09:08:55.629Z', + thresholdResult: { + definition: { + rules: [ + { + key: 'exist', + expression: '==true', + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: 'error.main', + icon: 'scorecardErrorStatusIcon', + }, + ], + }, + status: 'success', + evaluation: 'missing', + }, + }, + }, +]; diff --git a/workspaces/scorecard/packages/app-legacy/src/App.tsx b/workspaces/scorecard/packages/app-legacy/src/App.tsx index b67857d291..c040371324 100644 --- a/workspaces/scorecard/packages/app-legacy/src/App.tsx +++ b/workspaces/scorecard/packages/app-legacy/src/App.tsx @@ -217,6 +217,66 @@ const mountPoints: HomePageCardMountPoint[] = [ }, }, }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-filecheck.license', + title: 'Scorecard: LICENSE file exists', + cardLayout: { + width: { + minColumns: 3, + maxColumns: 12, + defaultColumns: 4, + }, + height: { + minRows: 5, + maxRows: 12, + defaultRows: 6, + }, + }, + layouts: { + xl: { w: 4, h: 6 }, + lg: { w: 4, h: 6 }, + md: { w: 4, h: 6 }, + sm: { w: 4, h: 6 }, + xs: { w: 4, h: 6 }, + xxs: { w: 4, h: 6 }, + }, + props: { + aggregationId: 'licenseFileExistsKpi', + }, + }, + }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-filecheck.codeowners', + title: 'Scorecard: CODEOWNERS file exists', + cardLayout: { + width: { + minColumns: 3, + maxColumns: 12, + defaultColumns: 4, + }, + height: { + minRows: 5, + maxRows: 12, + defaultRows: 6, + }, + }, + layouts: { + xl: { w: 4, h: 6, x: 4 }, + lg: { w: 4, h: 6, x: 4 }, + md: { w: 4, h: 6, x: 4 }, + sm: { w: 4, h: 6, x: 4 }, + xs: { w: 4, h: 6, x: 4 }, + xxs: { w: 4, h: 6, x: 4 }, + }, + props: { + aggregationId: 'filecheck.codeowners', + }, + }, + }, { Component: ScorecardHomepageCard as ComponentType, config: { @@ -251,14 +311,24 @@ const mountPoints: HomePageCardMountPoint[] = [ title: 'Metric (Needs currently a page reload after change!)', type: 'string', default: 'jira.open_issues', - enum: ['jira.open_issues', 'github.open_prs'], + enum: [ + 'jira.open_issues', + 'github.open_prs', + 'filecheck.license', + 'filecheck.codeowners', + ], }, }, }, uiSchema: { metricId: { 'ui:widget': 'RadioWidget', - 'ui:enumNames': ['Jira Open Issues', 'GitHub Open PRs'], + 'ui:enumNames': [ + 'Jira Open Issues', + 'GitHub Open PRs', + 'LICENSE file exists', + 'CODEOWNERS file exists', + ], }, }, }, diff --git a/workspaces/scorecard/packages/backend/package.json b/workspaces/scorecard/packages/backend/package.json index 552794cb09..443894d59f 100644 --- a/workspaces/scorecard/packages/backend/package.json +++ b/workspaces/scorecard/packages/backend/package.json @@ -47,6 +47,7 @@ "@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-dependabot": "workspace:^", + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^", "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf": "workspace:^", diff --git a/workspaces/scorecard/packages/backend/src/index.ts b/workspaces/scorecard/packages/backend/src/index.ts index b680c36969..e346145397 100644 --- a/workspaces/scorecard/packages/backend/src/index.ts +++ b/workspaces/scorecard/packages/backend/src/index.ts @@ -72,6 +72,11 @@ backend.add( '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira' ), ); +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck' + ), +); backend.add( import( '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf' diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/.eslintrc.js b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/README.md new file mode 100644 index 0000000000..17e272448f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/README.md @@ -0,0 +1,126 @@ +# Scorecard Backend Module for File Checks + +This is an extension module to the `backstage-plugin-scorecard-backend` plugin. It provides configurable file-existence metrics for software components registered in the Backstage catalog, checking whether specific files (e.g., `README.md`, `LICENSE`, `CODEOWNERS`) are present in a component's source repository. + +## Prerequisites + +Before installing this module, ensure that the Scorecard backend plugin is integrated into your Backstage instance. Follow the [Scorecard backend plugin README](../scorecard-backend/README.md) for setup instructions. + +Entities must have a `backstage.io/source-location` annotation so the module can resolve the source repository and read its file tree. + +## Installation + +To install this backend module: + +```bash +# From your root directory +yarn workspace backend add @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck +``` + +```ts +// packages/backend/src/index.ts +import { createBackend } from '@backstage/backend-defaults'; + +const backend = createBackend(); + +// Scorecard backend plugin +backend.add( + import('@red-hat-developer-hub/backstage-plugin-scorecard-backend'), +); + +// Install the File Check module +/* highlight-add-next-line */ +backend.add( + import( + '@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck' + ), +); + +backend.start(); +``` + +## Configuration + +### Files Configuration + +Define which files to check under `scorecard.plugins.filecheck.files` in your `app-config.yaml`. Keys become the metric identifier suffix and values are relative file paths inside the repository: + +```yaml +# app-config.yaml +scorecard: + plugins: + filecheck: + files: + readme: README.md + license: LICENSE + codeowners: CODEOWNERS + contributing: CONTRIBUTING.md +``` + +This produces the following metrics: + +| Metric ID | Checked file path | +| ------------------------ | ----------------- | +| `filecheck.readme` | `README.md` | +| `filecheck.license` | `LICENSE` | +| `filecheck.codeowners` | `CODEOWNERS` | +| `filecheck.contributing` | `CONTRIBUTING.md` | + +If no files are configured, no metrics are registered and the module has no effect. + +**File path rules:** + +- Paths must be relative (no leading `/`, `./` or `../`). +- Paths must not contain newlines, quotes (`"`), or backslashes. + +### Entity Requirements + +Entities must have the `backstage.io/source-location` annotation set (typically added automatically by the catalog ingestion process): + +```yaml +# catalog-info.yaml +metadata: + annotations: + backstage.io/source-location: url:https://github.com/myorg/my-service +``` + +## Available Metrics + +### File existence check (`filecheck.`) + +Each configured file produces one boolean metric. + +- **Metric ID**: `filecheck.` (where `` is the key from the `files` config) +- **Type**: Boolean +- **Datasource**: `filecheck` +- **Default thresholds**: + + | Threshold key | Expression | Description | + | ------------- | ---------- | ---------------------- | + | `exist` | `==true` | File exists (success) | + | `missing` | `==false` | File is absent (error) | + +### Threshold Configuration + +You can override the default thresholds via `app-config.yaml`. Check out the detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md). + +## Schedule Configuration + +The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. You can change this schedule in the `app-config.yaml` file: + +```yaml +scorecard: + plugins: + filecheck: + schedule: + frequency: + cron: '0 6 * * *' + timeout: + minutes: 5 + initialDelay: + seconds: 5 +``` + +The schedule configuration follows Backstage's `SchedulerServiceTaskScheduleDefinitionConfig` [schema](https://github.com/backstage/backstage/blob/master/packages/backend-plugin-api/src/services/definitions/SchedulerService.ts#L157). + +Note: all configured file checks share a single schedule — the module fetches each entity's repository tree once per run and checks all configured paths in that single request. diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/config.d.ts new file mode 100644 index 0000000000..b90465df54 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/config.d.ts @@ -0,0 +1,33 @@ +/* + * 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 { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api'; + +export interface Config { + /** Configuration for scorecard plugin */ + scorecard?: { + /** Configuration for scorecard plugins/datasources */ + plugins?: { + /** File existence check configuration */ + filecheck?: { + /** File existence checks configuration. Keys are metric identifier suffixes, values are relative file paths. */ + files?: { + [metricId: string]: string; + }; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; + }; + }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/package.json b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/package.json new file mode 100644 index 0000000000..d4552bf6f9 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/package.json @@ -0,0 +1,70 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck", + "version": "0.0.0", + "license": "Apache-2.0", + "description": "The filecheck backend module for the scorecard plugin.", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public" + }, + "backstage": { + "role": "backend-plugin-module", + "pluginId": "scorecard", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-scorecard-backend" + }, + "configSchema": "config.d.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "start": "backstage-cli package start", + "test": "backstage-cli package test", + "tsc": "tsc", + "prettier:check": "prettier --ignore-unknown --check .", + "prettier:fix": "prettier --ignore-unknown --write ." + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.8.0", + "@backstage/catalog-client": "^1.14.0", + "@backstage/catalog-model": "^1.7.7", + "@backstage/errors": "^1.2.7", + "@backstage/integration": "^2.0.0", + "@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", + "@backstage/config": "^1.3.6" + }, + "files": [ + "config.d.ts", + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/redhat-developer/rhdh-plugins", + "directory": "workspaces/scorecard/plugins/scorecard-backend-module-filecheck" + }, + "keywords": [ + "backstage", + "plugin" + ], + "homepage": "https://red.ht/rhdh", + "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues", + "author": "Red Hat" +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/report.api.md b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/report.api.md new file mode 100644 index 0000000000..a8932e0816 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/report.api.md @@ -0,0 +1,11 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +const scorecardModuleFilecheck: BackendFeature; +export default scorecardModuleFilecheck; +``` diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/clients/FilecheckClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/clients/FilecheckClient.ts new file mode 100644 index 0000000000..0d88547087 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/clients/FilecheckClient.ts @@ -0,0 +1,94 @@ +/* + * 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 { + CacheService, + UrlReaderService, +} from '@backstage/backend-plugin-api'; +import { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { NotModifiedError } from '@backstage/errors'; + +/** TTL for cache entries: 1 hour in milliseconds */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +type CacheEntry = { + etag: string; + foundPaths: string[]; +}; + +export class FilecheckClient { + private readonly urlReader: UrlReaderService; + private readonly cache: CacheService; + + constructor(urlReader: UrlReaderService, cache: CacheService) { + this.urlReader = urlReader; + this.cache = cache; + } + + /** + * Downloads the entity's repository tree once and checks which of the given + * file paths exist. Returns a map of path → boolean. + * + * A filter is applied so that only the requested paths are streamed from the + * archive, keeping bandwidth usage proportional to the number of configured + * files rather than the size of the whole repository. + * + * Results are cached per (target URL, file list) pair using ETags so that + * subsequent scheduler runs skip re-downloading trees that have not changed. + * Entries expire after one hour so stale data for removed entities is + * eventually evicted. + */ + async checkFiles( + entity: Entity, + filePaths: string[], + ): Promise> { + const { target } = getEntitySourceLocation(entity); + + const sortedPaths = [...filePaths].sort((a, b) => a.localeCompare(b)); + const cacheKey = `${target}\0${sortedPaths.join('\0')}`; + const pathsSet = new Set(filePaths); + const cached = await this.cache.get(cacheKey); + + let foundPaths: Set; + + try { + const tree = await this.urlReader.readTree(target, { + etag: cached?.etag, + filter: filePath => pathsSet.has(filePath), + }); + + const files = await tree.files(); + foundPaths = new Set(files.map(f => f.path)); + await this.cache.set( + cacheKey, + { etag: tree.etag, foundPaths: [...foundPaths] }, + { ttl: CACHE_TTL_MS }, + ); + } catch (error: any) { + if (cached && error instanceof NotModifiedError) { + foundPaths = new Set(cached.foundPaths); + } else { + throw error; + } + } + + const result = new Map(); + for (const filePath of filePaths) { + result.set(filePath, foundPaths.has(filePath)); + } + return result; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/index.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/index.ts new file mode 100644 index 0000000000..e1894e3b48 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/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 filecheck backend module for the scorecard plugin. + * + * @packageDocumentation + */ + +export { scorecardModuleFilecheck as default } from './module'; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckConfig.ts new file mode 100644 index 0000000000..12ef0339ef --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckConfig.ts @@ -0,0 +1,92 @@ +/* + * 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 { Config } from '@backstage/config'; +import { + type ThresholdConfig, + ScorecardThresholdRuleColors, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export type FilecheckEntry = { + id: string; + path: string; +}; + +export type FilecheckConfig = { + files: FilecheckEntry[]; +}; + +export const DEFAULT_FILECHECK_THRESHOLDS: ThresholdConfig = { + rules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], +}; + +const INVALID_PATH_CHARS = /[\n\r"\\]/; + +export function validateFilePath(id: string, path: string): void { + if (INVALID_PATH_CHARS.test(path)) { + throw new Error( + `Invalid file path for '${id}': path must not contain newlines, quotes, or backslashes`, + ); + } + if (path.startsWith('/') || path.startsWith('./') || path.startsWith('../')) { + throw new Error( + `Invalid file path for '${id}': path must be relative without leading './', '../' or '/'`, + ); + } +} + +/** + * Parses the filecheck configuration from the root Backstage config. + * Returns undefined if no files are configured. + */ +export function parseFilecheckConfig( + config: Config, +): FilecheckConfig | undefined { + const filesConfig = config.getOptionalConfig( + 'scorecard.plugins.filecheck.files', + ); + + if (!filesConfig) { + return undefined; + } + + const ids = filesConfig.keys(); + + if (ids.length === 0) { + return undefined; + } + + const files: FilecheckEntry[] = ids.map(id => { + const path = filesConfig.getString(id); + validateFilePath(id, path); + return { id, path }; + }); + + return { files }; +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.test.ts new file mode 100644 index 0000000000..07efdac8e5 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.test.ts @@ -0,0 +1,520 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; +import type { + CacheService, + UrlReaderService, +} from '@backstage/backend-plugin-api'; +import { NotModifiedError } from '@backstage/errors'; +import { DEFAULT_FILECHECK_THRESHOLDS } from './FilecheckConfig'; +import { createFilecheckMetricProvider } from './FilecheckMetricProviderFactory'; + +function createMockCacheService(): jest.Mocked { + const store = new Map(); + return { + get: jest.fn(async (key: string) => store.get(key)), + set: jest.fn(async (key: string, value: unknown) => { + store.set(key, value); + }), + delete: jest.fn(async (key: string) => { + store.delete(key); + }), + withOptions: jest.fn(), + } as unknown as jest.Mocked; +} + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/my-repo/tree/main/', + }), +})); + +/** + * Creates a mock UrlReaderService whose readTree returns a tree containing + * only the paths in existingFiles (filtered by the caller's filter option). + * File paths are relative to the tree root (e.g. "README.md", not a full URL). + */ +function createMockUrlReader( + existingFiles: Set, +): jest.Mocked { + return { + readUrl: jest.fn(), + readTree: jest.fn( + async ( + _url: string, + opts?: { + etag?: string; + filter?: (path: string, info?: { size: number }) => boolean; + }, + ) => { + const files = [...existingFiles] + .filter(p => !opts?.filter || opts.filter(p)) + .map(p => ({ path: p, content: async () => Buffer.from('') })); + return { + files: async () => files, + etag: 'test-etag', + archive: async () => { + throw new Error('not implemented'); + }, + }; + }, + ), + search: jest.fn(), + } as unknown as jest.Mocked; +} + +const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'backstage.io/source-location': + 'url:https://github.com/org/my-repo/tree/main/', + }, + }, +}; + +describe('FilecheckMetricProvider', () => { + describe('createFilecheckMetricProvider', () => { + const mockUrlReader = createMockUrlReader(new Set()); + const mockCacheService = createMockCacheService(); + + it('should return undefined when no files configuration is provided', () => { + const provider = createFilecheckMetricProvider( + new ConfigReader({}), + mockUrlReader, + mockCacheService, + ); + expect(provider).toBeUndefined(); + }); + + it('should return undefined when files object is empty', () => { + const provider = createFilecheckMetricProvider( + new ConfigReader({ + scorecard: { plugins: { filecheck: { files: {} } } }, + }), + mockUrlReader, + mockCacheService, + ); + expect(provider).toBeUndefined(); + }); + + it('should create provider with files configuration', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md', license: 'LICENSE' }, + }, + }, + }, + }); + + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + mockCacheService, + ); + + expect(provider).toBeDefined(); + expect(provider?.getMetricIds()).toEqual([ + 'filecheck.readme', + 'filecheck.license', + ]); + }); + + it('should throw error when file path contains a double quote', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { files: { bad: 'path/with"quote.txt' } }, + }, + }, + }); + + expect(() => + createFilecheckMetricProvider(config, mockUrlReader, mockCacheService), + ).toThrow( + "Invalid file path for 'bad': path must not contain newlines, quotes, or backslashes", + ); + }); + + it('should throw error when file path contains a newline', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { files: { bad: 'path/with\nnewline' } }, + }, + }, + }); + + expect(() => + createFilecheckMetricProvider(config, mockUrlReader, mockCacheService), + ).toThrow( + "Invalid file path for 'bad': path must not contain newlines, quotes, or backslashes", + ); + }); + + it('should throw error when file path contains a backslash', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { bad: String.raw`path\file.txt` }, + }, + }, + }, + }); + + expect(() => + createFilecheckMetricProvider(config, mockUrlReader, mockCacheService), + ).toThrow( + "Invalid file path for 'bad': path must not contain newlines, quotes, or backslashes", + ); + }); + + it('should throw error when file path starts with /', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { files: { bad: '/absolute/path.txt' } }, + }, + }, + }); + + expect(() => + createFilecheckMetricProvider(config, mockUrlReader, mockCacheService), + ).toThrow( + "Invalid file path for 'bad': path must be relative without leading './', '../' or '/'", + ); + }); + + it('should throw error when file path starts with ./', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { files: { bad: './relative/path.txt' } }, + }, + }, + }); + + expect(() => + createFilecheckMetricProvider(config, mockUrlReader, mockCacheService), + ).toThrow( + "Invalid file path for 'bad': path must be relative without leading './', '../' or '/'", + ); + }); + }); + + describe('provider methods', () => { + const mockUrlReader = createMockUrlReader(new Set()); + const mockCacheService = createMockCacheService(); + + const provider = createFilecheckMetricProvider( + new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { + readme: 'README.md', + codeowners: 'CODEOWNERS', + dockerfile: 'Dockerfile', + }, + }, + }, + }, + }), + mockUrlReader, + mockCacheService, + ); + + it('should return correct provider ID', () => { + expect(provider?.getProviderId()).toBe('filecheck'); + }); + + it('should return correct datasource ID', () => { + expect(provider?.getProviderDatasourceId()).toBe('filecheck'); + }); + + it('should return correct metric type', () => { + expect(provider?.getMetricType()).toBe('boolean'); + }); + + it('should return all metric IDs', () => { + expect(provider?.getMetricIds()).toEqual([ + 'filecheck.readme', + 'filecheck.codeowners', + 'filecheck.dockerfile', + ]); + }); + + it('should return default file check thresholds', () => { + expect(provider?.getMetricThresholds()).toEqual( + DEFAULT_FILECHECK_THRESHOLDS, + ); + }); + + it('should return correct catalog filter', () => { + expect(provider?.getCatalogFilter()).toEqual({ + 'metadata.annotations.backstage.io/source-location': expect.any(Symbol), + }); + }); + + it('should return all metrics with correct metadata', () => { + const metrics = provider?.getMetrics(); + + expect(metrics).toHaveLength(3); + expect(metrics?.[0]).toEqual({ + id: 'filecheck.readme', + title: 'File: README.md', + description: 'Checks if README.md exists in the repository.', + type: 'boolean', + history: true, + }); + expect(metrics?.[1]).toEqual({ + id: 'filecheck.codeowners', + title: 'File: CODEOWNERS', + description: 'Checks if CODEOWNERS exists in the repository.', + type: 'boolean', + history: true, + }); + }); + + it('should return first metric for backward compatibility via getMetric()', () => { + const metric = provider?.getMetric(); + + expect(metric).toEqual({ + id: 'filecheck.readme', + title: 'File: README.md', + description: 'Checks if README.md exists in the repository.', + type: 'boolean', + history: true, + }); + }); + }); + + describe('calculateMetrics', () => { + it('should return true for existing files and false for missing files', async () => { + const existingFiles = new Set(['README.md']); + const mockUrlReader = createMockUrlReader(existingFiles); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md', license: 'LICENSE' }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + const result = await provider?.calculateMetrics(mockEntity); + + expect(result?.get('filecheck.readme')).toBe(true); + expect(result?.get('filecheck.license')).toBe(false); + }); + + it('should check all configured files with a single readTree call', async () => { + const existingFiles = new Set(['README.md', 'LICENSE', 'Dockerfile']); + const mockUrlReader = createMockUrlReader(existingFiles); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { + readme: 'README.md', + license: 'LICENSE', + codeowners: 'CODEOWNERS', + dockerfile: 'Dockerfile', + }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + const result = await provider?.calculateMetrics(mockEntity); + + expect(result?.get('filecheck.readme')).toBe(true); + expect(result?.get('filecheck.license')).toBe(true); + expect(result?.get('filecheck.codeowners')).toBe(false); + expect(result?.get('filecheck.dockerfile')).toBe(true); + expect(mockUrlReader.readTree).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors from readTree', async () => { + const mockUrlReader: jest.Mocked = { + readUrl: jest.fn(), + readTree: jest.fn().mockRejectedValue(new Error('Auth failure')), + search: jest.fn(), + } as unknown as jest.Mocked; + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md' }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + await expect(provider?.calculateMetrics(mockEntity)).rejects.toThrow( + 'Auth failure', + ); + }); + + it('should return first metric result for legacy calculateMetric()', async () => { + const existingFiles = new Set(['README.md']); + const mockUrlReader = createMockUrlReader(existingFiles); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md', license: 'LICENSE' }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + const result = await provider?.calculateMetric(mockEntity); + + expect(result).toBe(true); + }); + + it('should return false when metric result is not found in legacy calculateMetric()', async () => { + const mockUrlReader = createMockUrlReader(new Set()); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md' }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + const result = await provider?.calculateMetric(mockEntity); + + expect(result).toBe(false); + }); + + it('should handle bare repo source locations without branch ref', async () => { + const { getEntitySourceLocation } = jest.requireMock( + '@backstage/catalog-model', + ); + getEntitySourceLocation.mockReturnValueOnce({ + type: 'url', + target: 'https://github.com/org/my-repo', + }); + + const existingFiles = new Set(['README.md']); + const mockUrlReader = createMockUrlReader(existingFiles); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md' }, + }, + }, + }, + }); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + createMockCacheService(), + ); + + const result = await provider?.calculateMetrics(mockEntity); + + expect(result?.get('filecheck.readme')).toBe(true); + expect(mockUrlReader.readTree).toHaveBeenCalledWith( + 'https://github.com/org/my-repo', + expect.objectContaining({ filter: expect.any(Function) }), + ); + }); + + it('should use ETag cache to skip re-downloading unchanged trees', async () => { + const existingFiles = new Set(['README.md']); + const mockUrlReader = createMockUrlReader(existingFiles); + + const config = new ConfigReader({ + scorecard: { + plugins: { + filecheck: { + files: { readme: 'README.md' }, + }, + }, + }, + }); + const sharedCache = createMockCacheService(); + const provider = createFilecheckMetricProvider( + config, + mockUrlReader, + sharedCache, + ); + + await provider?.calculateMetrics(mockEntity); + + (mockUrlReader.readTree as jest.Mock).mockRejectedValueOnce( + new NotModifiedError(), + ); + + const result = await provider?.calculateMetrics(mockEntity); + + expect(result?.get('filecheck.readme')).toBe(true); + expect(mockUrlReader.readTree).toHaveBeenCalledTimes(2); + expect(mockUrlReader.readTree).toHaveBeenNthCalledWith( + 2, + 'https://github.com/org/my-repo/tree/main/', + expect.objectContaining({ etag: 'test-etag' }), + ); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.ts new file mode 100644 index 0000000000..8bafab8b79 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProvider.ts @@ -0,0 +1,102 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { FilecheckClient } from '../clients/FilecheckClient'; +import { + FilecheckConfig, + DEFAULT_FILECHECK_THRESHOLDS, +} from './FilecheckConfig'; + +export class FilecheckMetricProvider implements MetricProvider<'boolean'> { + private readonly client: FilecheckClient; + private readonly filesConfig: FilecheckConfig; + private readonly thresholds: ThresholdConfig; + + constructor( + client: FilecheckClient, + filesConfig: FilecheckConfig, + thresholds?: ThresholdConfig, + ) { + this.client = client; + this.filesConfig = filesConfig; + this.thresholds = thresholds ?? DEFAULT_FILECHECK_THRESHOLDS; + } + + getProviderDatasourceId(): string { + return 'filecheck'; + } + + getProviderId(): string { + return 'filecheck'; + } + + getMetricIds(): string[] { + return this.filesConfig.files.map(f => `filecheck.${f.id}`); + } + + getMetricType(): 'boolean' { + return 'boolean'; + } + + getMetric(): Metric<'boolean'> { + return this.getMetrics()[0]; + } + + getMetrics(): Metric<'boolean'>[] { + return this.filesConfig.files.map(f => ({ + id: `filecheck.${f.id}`, + title: `File: ${f.path}`, + description: `Checks if ${f.path} exists in the repository.`, + type: 'boolean' as const, + history: true, + })); + } + + getMetricThresholds(): ThresholdConfig { + return this.thresholds; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.backstage.io/source-location': + CATALOG_FILTER_EXISTS, + }; + } + + async calculateMetric(entity: Entity): Promise { + const results = await this.calculateMetrics(entity); + const firstId = this.getMetricIds()[0]; + return results.get(firstId) ?? false; + } + + async calculateMetrics(entity: Entity): Promise> { + const filePaths = this.filesConfig.files.map(f => f.path); + const existsMap = await this.client.checkFiles(entity, filePaths); + + const results = new Map(); + for (const file of this.filesConfig.files) { + results.set(`filecheck.${file.id}`, existsMap.get(file.path) ?? false); + } + return results; + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProviderFactory.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProviderFactory.ts new file mode 100644 index 0000000000..b66d9439cf --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/metricProviders/FilecheckMetricProviderFactory.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 type { + CacheService, + UrlReaderService, +} from '@backstage/backend-plugin-api'; +import type { Config } from '@backstage/config'; +import { FilecheckClient } from '../clients/FilecheckClient'; +import { parseFilecheckConfig } from './FilecheckConfig'; +import { FilecheckMetricProvider } from './FilecheckMetricProvider'; + +/** + * Creates a FilecheckMetricProvider from root Backstage config and services. + * Returns undefined if no files are configured under `scorecard.plugins.filecheck.files`. + */ +export function createFilecheckMetricProvider( + config: Config, + urlReader: UrlReaderService, + cache: CacheService, +): FilecheckMetricProvider | undefined { + const filesConfig = parseFilecheckConfig(config); + if (!filesConfig) { + return undefined; + } + + const client = new FilecheckClient(urlReader, cache); + return new FilecheckMetricProvider(client, filesConfig); +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/module.ts new file mode 100644 index 0000000000..b5a1a82c61 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-filecheck/src/module.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 { + coreServices, + createBackendModule, +} from '@backstage/backend-plugin-api'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { createFilecheckMetricProvider } from './metricProviders/FilecheckMetricProviderFactory'; + +export const scorecardModuleFilecheck = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'filecheck', + register(reg) { + reg.registerInit({ + deps: { + config: coreServices.rootConfig, + urlReader: coreServices.urlReader, + cache: coreServices.cache, + metrics: scorecardMetricsExtensionPoint, + }, + async init({ config, urlReader, cache, metrics }) { + const provider = createFilecheckMetricProvider( + config, + urlReader, + cache, + ); + if (provider) { + metrics.addMetricProvider(provider); + } + }, + }); + }, +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 69eeb599ca..4b1f821707 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend/README.md @@ -88,12 +88,13 @@ For more information about schedule configuration options, see the [Metric Colle The following metric providers are available: -| Provider | Metric ID | Title | Description | Type | -| -------------- | ------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------ | -| **GitHub** | `github.open_prs` | GitHub open PRs | Count of open Pull Requests in GitHub | number | -| **Jira** | `jira.open_issues` | Jira open issues | The number of opened issues in Jira | number | -| **OpenSSF** | `openssf.*` | OpenSSF Security Scorecards | 18 security metrics from OpenSSF Scorecards (e.g., `openssf.code_review`, `openssf.maintained`). Each returns a score from 0-10. | number | -| **Dependabot** | `dependabot.*` | Dependabot Alerts | Critical, High, Medium and Low CVE Alerts | number | +| Provider | Metric ID | Title | Description | Type | +| -------------- | ------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------- | +| **GitHub** | `github.open_prs` | GitHub open PRs | Count of open Pull Requests in GitHub | number | +| **Filecheck** | `filecheck.*` | File Checks | Checks whether specific files (e.g., `README.md`, `LICENSE`, `CODEOWNERS`) exist in a repository. | boolean | +| **Jira** | `jira.open_issues` | Jira open issues | The number of opened issues in Jira | number | +| **OpenSSF** | `openssf.*` | OpenSSF Security Scorecards | 18 security metrics from OpenSSF Scorecards (e.g., `openssf.code_review`, `openssf.maintained`). Each returns a score from 0-10. | number | +| **Dependabot** | `dependabot.*` | Dependabot Alerts | Critical, High, Medium and Low CVE Alerts | number | To use these providers, install the corresponding backend modules: @@ -101,6 +102,7 @@ To use these providers, install the corresponding backend modules: - Jira: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira`](../scorecard-backend-module-jira/README.md) - OpenSSF: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf`](../scorecard-backend-module-openssf/README.md) - Dependabot: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot`](../scorecard-backend-module-dependabot/README.md) +- Filecheck: [`@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck`](../scorecard-backend-module-filecheck/README.md) ### Disabling Metrics diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts index 66043f0158..e067a394b3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts @@ -52,6 +52,11 @@ export const buildMockMetricProvidersRegistry = ({ const getMetric = provider || metricsList ? jest.fn().mockImplementation((metricId: string) => { + if (provider?.getMetrics) { + const found = provider.getMetrics().find(m => m.id === metricId); + if (found) return found; + } + const pMetric = provider?.getMetric(); if (pMetric && pMetric.id === metricId) return pMetric; @@ -67,7 +72,7 @@ export const buildMockMetricProvidersRegistry = ({ return { ...mockMetricProvidersRegistry, getProvider, - listMetrics, getMetric, + listMetrics, } as unknown as jest.Mocked; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts index 5408ec8b32..98eaed269b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts @@ -24,6 +24,19 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +type CatalogFilterValue = string | symbol | (string | symbol)[]; + +const BOOLEAN_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '==true' }, + { key: 'error', expression: '==false' }, + ], +}; + +const MOCK_CATALOG_FILTER: Record = { + 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, +}; + abstract class MockMetricProvider implements MetricProvider { @@ -38,10 +51,8 @@ abstract class MockMetricProvider abstract getMetricThresholds(): ThresholdConfig; - getCatalogFilter(): Record { - return { - 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, - }; + getCatalogFilter(): Record { + return MOCK_CATALOG_FILTER; } getProviderDatasourceId(): string { @@ -107,12 +118,7 @@ export class MockBooleanProvider extends MockMetricProvider<'boolean'> { super('boolean', providerId, datasourceId, title, description, value); } getMetricThresholds(): ThresholdConfig { - return { - rules: [ - { key: 'success', expression: '==true' }, - { key: 'error', expression: '==false' }, - ], - }; + return BOOLEAN_THRESHOLDS; } } export const githubNumberProvider = new MockNumberProvider( @@ -139,3 +145,99 @@ export const jiraBooleanMetricMetadata = { description: 'Mock boolean description.', type: 'boolean' as const, }; + +/** + * Mock batch provider that exposes multiple metrics + */ +export class MockBatchBooleanProvider implements MetricProvider<'boolean'> { + private readonly metricConfigs: Array<{ id: string; path: string }>; + + constructor( + private readonly datasourceId: string, + private readonly providerIdPrefix: string, + metricConfigs: Array<{ id: string; path: string }>, + ) { + this.metricConfigs = metricConfigs; + } + + getProviderDatasourceId(): string { + return this.datasourceId; + } + + getProviderId(): string { + return this.providerIdPrefix; + } + + getMetricType(): 'boolean' { + return 'boolean'; + } + + getMetricIds(): string[] { + return this.metricConfigs.map(c => `${this.providerIdPrefix}.${c.id}`); + } + + getMetrics(): Metric<'boolean'>[] { + return this.metricConfigs.map(c => ({ + id: `${this.providerIdPrefix}.${c.id}`, + title: `File: ${c.path}`, + description: `Checks if ${c.path} exists.`, + type: 'boolean' as const, + })); + } + + getMetric(): Metric<'boolean'> { + return this.getMetrics()[0]; + } + + getMetricThresholds(): ThresholdConfig { + return BOOLEAN_THRESHOLDS; + } + + getCatalogFilter(): Record { + return MOCK_CATALOG_FILTER; + } + + async calculateMetric(_entity: Entity): Promise { + const results = await this.calculateMetrics(_entity); + return results.get(this.getMetricIds()[0]) ?? false; + } + + async calculateMetrics(_entity: Entity): Promise> { + const results = new Map(); + for (const config of this.metricConfigs) { + results.set(`${this.providerIdPrefix}.${config.id}`, true); + } + return results; + } +} + +export const filecheckBatchProvider = new MockBatchBooleanProvider( + 'filecheck', + 'filecheck', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + { id: 'codeowners', path: 'CODEOWNERS' }, + ], +); + +export const filecheckBatchMetrics = [ + { + id: 'filecheck.readme', + title: 'File: README.md', + description: 'Checks if README.md exists.', + type: 'boolean' as const, + }, + { + id: 'filecheck.license', + title: 'File: LICENSE', + description: 'Checks if LICENSE exists.', + type: 'boolean' as const, + }, + { + id: 'filecheck.codeowners', + title: 'File: CODEOWNERS', + description: 'Checks if CODEOWNERS exists.', + type: 'boolean' as const, + }, +]; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.test.ts index cc58d10cb8..b1442886ae 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.test.ts @@ -23,6 +23,9 @@ import { jiraBooleanProvider, MockNumberProvider, MockBooleanProvider, + MockBatchBooleanProvider, + filecheckBatchProvider, + filecheckBatchMetrics, } from '../../__fixtures__/mockProviders'; import { MockEntityBuilder } from '../../__fixtures__/mockEntityBuilder'; @@ -78,7 +81,8 @@ describe('MetricProvidersRegistry', () => { expect(() => registry.register(invalidProvider)).toThrow( new Error( - "Invalid metric provider with ID github.test_metric, provider ID must match metric ID 'different.id'", + "Invalid metric provider: metric ID 'github.test_metric' returned by getMetricIds() " + + 'does not have a corresponding metric in getMetrics()', ), ); }); @@ -139,6 +143,98 @@ describe('MetricProvidersRegistry', () => { ), ); }); + + describe('batch providers', () => { + it('should register batch provider with multiple metric IDs', () => { + expect(() => registry.register(filecheckBatchProvider)).not.toThrow(); + + expect(registry.listMetrics()).toEqual(filecheckBatchMetrics); + }); + + it('should store batch provider under each metric ID', () => { + registry.register(filecheckBatchProvider); + + // Should be able to get the same provider instance for each metric ID + const provider1 = registry.getProvider('filecheck.readme'); + const provider2 = registry.getProvider('filecheck.license'); + const provider3 = registry.getProvider('filecheck.codeowners'); + + expect(provider1).toBe(filecheckBatchProvider); + expect(provider2).toBe(filecheckBatchProvider); + expect(provider3).toBe(filecheckBatchProvider); + }); + + it('should throw ConflictError when batch provider metric ID conflicts with existing', () => { + const existingProvider = new MockBooleanProvider( + 'filecheck.readme', + 'filecheck', + ); + registry.register(existingProvider); + + expect(() => registry.register(filecheckBatchProvider)).toThrow( + new ConflictError( + "Metric provider with ID 'filecheck.readme' has already been registered", + ), + ); + }); + + it('should throw error when metric ID from getMetricIds has no corresponding metric', () => { + class InvalidBatchProvider extends MockBatchBooleanProvider { + getMetricIds(): string[] { + return ['filecheck.readme', 'filecheck.nonexistent']; + } + getMetrics() { + return [ + { + id: 'filecheck.readme', + title: 'README', + description: 'README check', + type: 'boolean' as const, + }, + ]; + } + } + + const invalidProvider = new InvalidBatchProvider( + 'filecheck', + 'filecheck', + [], + ); + + expect(() => registry.register(invalidProvider)).toThrow( + "Invalid metric provider: metric ID 'filecheck.nonexistent' returned by getMetricIds() " + + 'does not have a corresponding metric in getMetrics()', + ); + }); + + it('should throw error when batch provider metric ID has wrong format', () => { + class InvalidBatchProvider extends MockBatchBooleanProvider { + getMetricIds(): string[] { + return ['invalid_format']; + } + getMetrics() { + return [ + { + id: 'invalid_format', + title: 'Invalid', + description: 'Invalid', + type: 'boolean' as const, + }, + ]; + } + } + + const invalidProvider = new InvalidBatchProvider( + 'github', + 'filecheck', + [], + ); + + expect(() => registry.register(invalidProvider)).toThrow( + "Invalid metric provider with ID invalid_format, must have format 'github.' where metric name is not empty", + ); + }); + }); }); describe('getProvider', () => { @@ -153,7 +249,7 @@ describe('MetricProvidersRegistry', () => { it('should throw NotFoundError for unregistered provider', () => { expect(() => registry.getProvider('non_existent')).toThrow( new NotFoundError( - "Metric provider with ID 'non_existent' is not registered.", + "No metric provider registered for metric ID 'non_existent'.", ), ); }); @@ -174,10 +270,22 @@ describe('MetricProvidersRegistry', () => { it('should throw NotFoundError for unregistered provider', () => { expect(() => registry.getMetric('non_existent')).toThrow( new NotFoundError( - "Metric provider with ID 'non_existent' is not registered.", + "No metric provider registered for metric ID 'non_existent'.", ), ); }); + + it('should return specific metric from batch provider', () => { + registry.register(filecheckBatchProvider); + + const readmeMetric = registry.getMetric('filecheck.readme'); + const licenseMetric = registry.getMetric('filecheck.license'); + const codeownersMetric = registry.getMetric('filecheck.codeowners'); + + expect(readmeMetric).toEqual(filecheckBatchMetrics[0]); + expect(licenseMetric).toEqual(filecheckBatchMetrics[1]); + expect(codeownersMetric).toEqual(filecheckBatchMetrics[2]); + }); }); describe('calculateMetric', () => { @@ -197,7 +305,7 @@ describe('MetricProvidersRegistry', () => { registry.calculateMetric('non_existent', mockEntity), ).rejects.toThrow( new NotFoundError( - "Metric provider with ID 'non_existent' is not registered.", + "No metric provider registered for metric ID 'non_existent'.", ), ); }); @@ -221,11 +329,11 @@ describe('MetricProvidersRegistry', () => { expect(results).toHaveLength(2); expect(results[0]).toEqual({ - providerId: 'github.number_metric', + metricId: 'github.number_metric', value: 42, }); expect(results[1]).toEqual({ - providerId: 'jira.boolean_metric', + metricId: 'jira.boolean_metric', value: false, }); }); @@ -250,11 +358,11 @@ describe('MetricProvidersRegistry', () => { expect(results).toHaveLength(2); expect(results[0]).toEqual({ - providerId: 'github.number_metric', + metricId: 'github.number_metric', value: 42, }); expect(results[1]).toEqual({ - providerId: 'github.open_issues', + metricId: 'github.open_issues', value: 10, }); }); @@ -269,15 +377,15 @@ describe('MetricProvidersRegistry', () => { expect(results).toHaveLength(2); expect(results[0]).toEqual({ - providerId: 'github.number_metric', + metricId: 'github.number_metric', value: 42, }); expect(results[1]).toEqual({ - providerId: 'non_existent', + metricId: 'non_existent', error: expect.any(NotFoundError), }); expect(results[1].error?.message).toBe( - "Metric provider with ID 'non_existent' is not registered.", + "No metric provider registered for metric ID 'non_existent'.", ); }); }); @@ -298,6 +406,18 @@ describe('MetricProvidersRegistry', () => { expect(providers).toContain(githubNumberProvider); expect(providers).toContain(jiraBooleanProvider); }); + + it('should deduplicate batch providers that are stored under multiple metric IDs', () => { + registry.register(filecheckBatchProvider); + registry.register(jiraBooleanProvider); + + const providers = registry.listProviders(); + + // Should only have 2 providers, not 4 (batch provider has 3 metric IDs) + expect(providers).toHaveLength(2); + expect(providers).toContain(filecheckBatchProvider); + expect(providers).toContain(jiraBooleanProvider); + }); }); describe('listMetrics', () => { @@ -349,6 +469,46 @@ describe('MetricProvidersRegistry', () => { expect(metrics[0].id).toBe('github.number_metric'); expect(metrics[1].id).toBe('jira.boolean_metric'); }); + + describe('with batch providers', () => { + beforeEach(() => { + registry = new MetricProvidersRegistry(); + registry.register(filecheckBatchProvider); + registry.register(jiraBooleanProvider); + }); + + it('should return all metrics including batch provider metrics', () => { + const metrics = registry.listMetrics(); + + expect(metrics).toHaveLength(4); // 3 from batch + 1 from jira + expect(metrics.map(m => m.id)).toEqual([ + 'filecheck.readme', + 'filecheck.license', + 'filecheck.codeowners', + 'jira.boolean_metric', + ]); + }); + + it('should return specific batch provider metrics when filtered', () => { + const metrics = registry.listMetrics([ + 'filecheck.readme', + 'filecheck.codeowners', + ]); + + expect(metrics).toHaveLength(2); + expect(metrics[0].id).toBe('filecheck.readme'); + expect(metrics[1].id).toBe('filecheck.codeowners'); + }); + + it('should not duplicate metrics from batch providers', () => { + const metrics = registry.listMetrics(); + const metricIds = metrics.map(m => m.id); + + // Each metric ID should appear exactly once + const uniqueIds = [...new Set(metricIds)]; + expect(metricIds).toEqual(uniqueIds); + }); + }); }); describe('listMetricsByDatasource', () => { @@ -397,5 +557,39 @@ describe('MetricProvidersRegistry', () => { expect(metrics).toEqual([]); }); + + describe('with batch providers', () => { + beforeEach(() => { + registry = new MetricProvidersRegistry(); + registry.register(filecheckBatchProvider); + registry.register(githubNumberProvider); + registry.register(jiraBooleanProvider); + }); + + it('should return all metrics from batch provider for datasource', () => { + const metrics = registry.listMetricsByDatasource('filecheck'); + + expect(metrics).toHaveLength(3); + expect(metrics.map(m => m.id)).toContain('filecheck.readme'); + expect(metrics.map(m => m.id)).toContain('filecheck.license'); + expect(metrics.map(m => m.id)).toContain('filecheck.codeowners'); + }); + + it('should not include batch provider metrics under a different datasource', () => { + const githubMetrics = registry.listMetricsByDatasource('github'); + + expect(githubMetrics).toHaveLength(1); + expect(githubMetrics[0].id).toBe('github.number_metric'); + }); + + it('should not duplicate metrics from batch providers in datasource listing', () => { + const metrics = registry.listMetricsByDatasource('filecheck'); + const metricIds = metrics.map(m => m.id); + + // Each metric ID should appear exactly once + const uniqueIds = [...new Set(metricIds)]; + expect(metricIds).toEqual(uniqueIds); + }); + }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts index e900d4c0d1..eeed50306a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts @@ -30,54 +30,66 @@ export class MetricProvidersRegistry { private readonly datasourceIndex = new Map>(); register(metricProvider: MetricProvider): void { - const providerId = metricProvider.getProviderId(); const providerDatasource = metricProvider.getProviderDatasourceId(); - const metric = metricProvider.getMetric(); const metricType = metricProvider.getMetricType(); - if (providerId !== metric.id) { - throw new Error( - `Invalid metric provider with ID ${providerId}, provider ID must match metric ID '${metric.id}'`, - ); - } + // Support both single and batch providers + const metricIds = metricProvider.getMetricIds?.() ?? [ + metricProvider.getProviderId(), + ]; + const metrics = metricProvider.getMetrics?.() ?? [ + metricProvider.getMetric(), + ]; + + // Validate: Each metric ID must have a corresponding metric definition + for (const metricId of metricIds) { + const metric = metrics.find(m => m.id === metricId); + if (!metric) { + throw new Error( + `Invalid metric provider: metric ID '${metricId}' returned by getMetricIds() ` + + `does not have a corresponding metric in getMetrics()`, + ); + } - if (metricType !== metric.type) { - throw new Error( - `Invalid metric provider with ID ${providerId}, getMetricType() must match getMetric().type. Expected '${metricType}', but got '${metric.type}'`, - ); - } + if (metricType !== metric.type) { + throw new Error( + `Invalid metric provider with ID ${metricId}, getMetricType() must match ` + + `getMetric().type. Expected '${metricType}', but got '${metric.type}'`, + ); + } - const expectedPrefix = `${providerDatasource}.`; - if ( - !providerId.startsWith(expectedPrefix) || - providerId === expectedPrefix - ) { - throw new Error( - `Invalid metric provider with ID ${providerId}, must have format '${providerDatasource}.' where metric name is not empty`, - ); - } + // Validate: Provider ID format (datasource.metric_name) + const expectedPrefix = `${providerDatasource}.`; + if (!metricId.startsWith(expectedPrefix) || metricId === expectedPrefix) { + throw new Error( + `Invalid metric provider with ID ${metricId}, must have format ` + + `'${providerDatasource}.' where metric name is not empty`, + ); + } - if (this.metricProviders.has(providerId)) { - throw new ConflictError( - `Metric provider with ID '${providerId}' has already been registered`, - ); - } + if (this.metricProviders.has(metricId)) { + throw new ConflictError( + `Metric provider with ID '${metricId}' has already been registered`, + ); + } - this.metricProviders.set(providerId, metricProvider); + this.metricProviders.set(metricId, metricProvider); - let datasourceProviders = this.datasourceIndex.get(providerDatasource); - if (!datasourceProviders) { - datasourceProviders = new Set(); - this.datasourceIndex.set(providerDatasource, datasourceProviders); + // Index by datasource + let datasourceProviders = this.datasourceIndex.get(providerDatasource); + if (!datasourceProviders) { + datasourceProviders = new Set(); + this.datasourceIndex.set(providerDatasource, datasourceProviders); + } + datasourceProviders.add(metricId); } - datasourceProviders.add(providerId); } - getProvider(providerId: string): MetricProvider { - const metricProvider = this.metricProviders.get(providerId); + getProvider(metricId: string): MetricProvider { + const metricProvider = this.metricProviders.get(metricId); if (!metricProvider) { throw new NotFoundError( - `Metric provider with ID '${providerId}' is not registered.`, + `No metric provider registered for metric ID '${metricId}'.`, ); } return metricProvider; @@ -87,46 +99,70 @@ export class MetricProvidersRegistry { return this.metricProviders.has(providerId); } - getMetric(providerId: string): Metric { - return this.getProvider(providerId).getMetric(); + getMetric(metricId: string): Metric { + const provider = this.getProvider(metricId); + + // For batch providers, find the specific metric by ID + if (provider.getMetrics) { + const metrics = provider.getMetrics(); + const metric = metrics.find(m => m.id === metricId); + if (metric) { + return metric; + } + } + + return provider.getMetric(); } async calculateMetric( - providerId: string, + metricId: string, entity: Entity, ): Promise { - return this.getProvider(providerId).calculateMetric(entity); + return this.getProvider(metricId).calculateMetric(entity); } async calculateMetrics( - providerIds: string[], + metricIds: string[], entity: Entity, - ): Promise<{ providerId: string; value?: MetricValue; error?: Error }[]> { + ): Promise<{ metricId: string; value?: MetricValue; error?: Error }[]> { const results = await Promise.allSettled( - providerIds.map(providerId => this.calculateMetric(providerId, entity)), + metricIds.map(metricId => this.calculateMetric(metricId, entity)), ); return results.map((result, index) => { - const providerId = providerIds[index]; + const metricId = metricIds[index]; if (result.status === 'fulfilled') { - return { providerId, value: result.value }; + return { metricId, value: result.value }; } - return { providerId, error: result.reason as Error }; + return { metricId, error: result.reason as Error }; }); } listProviders(): MetricProvider[] { - return Array.from(this.metricProviders.values()); + // Deduplicate providers since batch providers are stored under multiple metric IDs + return [...new Set(this.metricProviders.values())]; } - listMetrics(providerIds?: string[]): Metric[] { - if (providerIds && providerIds.length !== 0) { - return providerIds - .map(providerId => this.metricProviders.get(providerId)?.getMetric()) + listMetrics(metricIds?: string[]): Metric[] { + if (metricIds && metricIds.length !== 0) { + return metricIds + .map(metricId => { + const provider = this.metricProviders.get(metricId); + if (!provider) return undefined; + + if (provider.getMetrics) { + const metrics = provider.getMetrics(); + return metrics.find(m => m.id === metricId); + } + + return provider.getMetric(); + }) .filter((m): m is Metric => m !== undefined); } - return [...this.metricProviders.values()].map(provider => - provider.getMetric(), + + // List all metrics from all providers (deduplicate batch providers) + return this.listProviders().flatMap( + provider => provider.getMetrics?.() ?? [provider.getMetric()], ); } @@ -137,9 +173,13 @@ export class MetricProvidersRegistry { return []; } - return Array.from(providerIdsOfDatasource) - .map(providerId => this.metricProviders.get(providerId)) - .filter((provider): provider is MetricProvider => provider !== undefined) - .map(provider => provider.getMetric()); + // Get unique providers for this datasource, then get their metrics + const providers = [...providerIdsOfDatasource] + .map(id => this.metricProviders.get(id)) + .filter((p): p is MetricProvider => p !== undefined); + + return [...new Set(providers)].flatMap( + provider => provider.getMetrics?.() ?? [provider.getMetric()], + ); } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts index 47ec80b3a6..2124ec08de 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts @@ -19,7 +19,10 @@ import { PullMetricsByProviderTask } from './PullMetricsByProviderTask'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { mergeEntityAndProviderThresholds } from '../../utils/mergeEntityAndProviderThresholds'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; -import { MockNumberProvider } from '../../../__fixtures__/mockProviders'; +import { + MockNumberProvider, + MockBatchBooleanProvider, +} from '../../../__fixtures__/mockProviders'; import type { Config } from '@backstage/config'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { mockDatabaseMetricValues } from '../../../__fixtures__/mockDatabaseMetricValues'; @@ -386,8 +389,7 @@ describe('PullMetricsByProviderTask', () => { it('should log completion', async () => { await (task as any).pullProviderMetrics(mockProvider, mockLogger); - expect(mockLogger.info).toHaveBeenNthCalledWith( - 2, + expect(mockLogger.info).toHaveBeenCalledWith( `Completed metric pull for github.test_metric: processed 2 entities`, ); }); @@ -434,5 +436,326 @@ describe('PullMetricsByProviderTask', () => { (task as any).pullProviderMetrics(mockProvider, mockLogger), ).rejects.toThrow('test error'); }); + + describe('batch providers', () => { + let mockBatchProvider: MockBatchBooleanProvider; + + beforeEach(() => { + mockBatchProvider = new MockBatchBooleanProvider( + 'filecheck', + 'filecheck', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + ], + ); + + task = new PullMetricsByProviderTask( + { + scheduler: mockScheduler, + logger: mockLogger, + database: mockDatabaseMetricValues, + config: mockConfig, + catalog: mockCatalog, + auth: mockAuth, + thresholdEvaluator: mockThresholdEvaluator, + }, + mockBatchProvider, + ); + }); + + it('should call calculateMetrics for batch providers', async () => { + const calculateMetricsSpy = jest.spyOn( + mockBatchProvider, + 'calculateMetrics', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + expect(calculateMetricsSpy).toHaveBeenCalledTimes(2); // Once per entity + expect(calculateMetricsSpy).toHaveBeenNthCalledWith(1, mockEntities[0]); + expect(calculateMetricsSpy).toHaveBeenNthCalledWith(2, mockEntities[1]); + }); + + it('should not call calculateMetric for batch providers', async () => { + const calculateMetricSpy = jest.spyOn( + mockBatchProvider, + 'calculateMetric', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + expect(calculateMetricSpy).not.toHaveBeenCalled(); + }); + + it('should create metric values for all metric IDs from batch provider', async () => { + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + // 2 entities × 2 metrics = 4 metric values + expect(createMetricValuesSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'filecheck.readme', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'filecheck.license', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'filecheck.readme', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'filecheck.license', + value: true, + status: 'success', + }), + ]), + ); + }); + + it('should evaluate thresholds for each metric in batch', async () => { + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + // 2 entities × 2 metrics = 4 threshold evaluations + expect( + mockThresholdEvaluator.getFirstMatchingThreshold, + ).toHaveBeenCalledTimes(4); + expect( + mockThresholdEvaluator.getFirstMatchingThreshold, + ).toHaveBeenCalledWith(true, 'boolean', { rules: mockThresholdRules }); + }); + + it('should create error records for all metrics when batch calculation fails', async () => { + jest + .spyOn(mockBatchProvider, 'calculateMetrics') + .mockRejectedValue(new Error('GitHub API error')); + + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + // Should create error records for both metrics for both entities + expect(createMetricValuesSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'filecheck.readme', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'filecheck.license', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'filecheck.readme', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'filecheck.license', + error_message: 'GitHub API error', + }), + ]), + ); + }); + + it('should handle threshold evaluation errors for individual batch metrics', async () => { + mockThresholdEvaluator.getFirstMatchingThreshold.mockImplementation( + () => { + throw new Error('Threshold evaluation failed'); + }, + ); + + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + // Should still create records but with error messages + expect(createMetricValuesSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'filecheck.readme', + value: true, + error_message: 'Threshold evaluation failed', + }), + ]), + ); + }); + + it('should skip all batch metrics for an entity when all metric IDs are disabled by annotation', async () => { + const disabledEntity = { + apiVersion: '1.0.0', + kind: 'Component', + metadata: { + name: 'disabled-all', + annotations: { + 'scorecard.io/disabled-metrics': + 'filecheck.readme,filecheck.license', + }, + }, + }; + + mockCatalog.queryEntities.mockReset().mockResolvedValueOnce({ + items: [disabledEntity], + pageInfo: { nextCursor: undefined }, + totalItems: 1, + }); + + const calculateMetricsSpy = jest.spyOn( + mockBatchProvider, + 'calculateMetrics', + ); + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + expect(calculateMetricsSpy).not.toHaveBeenCalled(); + expect(createMetricValuesSpy).toHaveBeenCalledWith([]); + }); + + it('should only create records for enabled metrics when some are disabled by annotation', async () => { + const partiallyDisabledEntity = { + apiVersion: '1.0.0', + kind: 'Component', + metadata: { + name: 'partial-disabled', + annotations: { + 'scorecard.io/disabled-metrics': 'filecheck.license', + }, + }, + }; + + mockCatalog.queryEntities.mockReset().mockResolvedValueOnce({ + items: [partiallyDisabledEntity], + pageInfo: { nextCursor: undefined }, + totalItems: 1, + }); + + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + expect(createMetricValuesSpy).toHaveBeenCalledWith([ + expect.objectContaining({ + catalog_entity_ref: 'component:default/partial-disabled', + metric_id: 'filecheck.readme', + value: true, + }), + ]); + }); + + it('should skip batch metrics disabled via scorecard.disabledMetrics app-config', async () => { + const configWithDisabled = mockServices.rootConfig({ + data: { + scorecard: { + schedule: scheduleConfig, + disabledMetrics: ['filecheck.license'], + }, + }, + }); + + task = new PullMetricsByProviderTask( + { + scheduler: mockScheduler, + logger: mockLogger, + database: mockDatabaseMetricValues, + config: configWithDisabled, + catalog: mockCatalog, + auth: mockAuth, + thresholdEvaluator: mockThresholdEvaluator, + }, + mockBatchProvider, + ); + + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + const savedRecords = createMetricValuesSpy.mock.calls[0][0]; + const metricIds = savedRecords.map( + (r: { metric_id: string }) => r.metric_id, + ); + expect(metricIds).not.toContain('filecheck.license'); + expect(metricIds).toContain('filecheck.readme'); + }); + + it('should create error records only for enabled metrics when batch calculation fails and some metrics are disabled', async () => { + jest + .spyOn(mockBatchProvider, 'calculateMetrics') + .mockRejectedValue(new Error('GitHub API error')); + + const partiallyDisabledEntity = { + apiVersion: '1.0.0', + kind: 'Component', + metadata: { + name: 'partial-disabled', + annotations: { + 'scorecard.io/disabled-metrics': 'filecheck.license', + }, + }, + }; + + mockCatalog.queryEntities.mockReset().mockResolvedValueOnce({ + items: [partiallyDisabledEntity], + pageInfo: { nextCursor: undefined }, + totalItems: 1, + }); + + const createMetricValuesSpy = jest.spyOn( + mockDatabaseMetricValues, + 'createMetricValues', + ); + await (task as any).pullProviderMetrics(mockBatchProvider, mockLogger); + + expect(createMetricValuesSpy).toHaveBeenCalledWith([ + expect.objectContaining({ + catalog_entity_ref: 'component:default/partial-disabled', + metric_id: 'filecheck.readme', + error_message: 'GitHub API error', + }), + ]); + const savedRecords = createMetricValuesSpy.mock.calls[0][0]; + expect(savedRecords).toHaveLength(1); + }); + + it('should get schedule from correct config path for batch provider', async () => { + (task as any).getScheduleFromConfig = jest + .fn() + .mockReturnValue({ frequency: { hours: 1 } }); + (task as any).pullProviderMetrics = jest + .fn() + .mockResolvedValue(undefined); + + await (task as any).start(); + + expect((task as any).getScheduleFromConfig).toHaveBeenCalledWith( + 'scorecard.plugins.filecheck.schedule', + ); + }); + }); }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts index 7ad8bdb320..4c2906022e 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts @@ -125,6 +125,8 @@ export class PullMetricsByProviderTask implements SchedulerTask { let cursor: string | undefined = undefined; const metricType = provider.getMetricType(); + const isBatchProvider = typeof provider.calculateMetrics === 'function'; + const metricIds = provider.getMetricIds?.() ?? [provider.getProviderId()]; try { do { @@ -141,6 +143,96 @@ export class PullMetricsByProviderTask implements SchedulerTask { const batchResults = await Promise.allSettled( entitiesResponse.items.map(async entity => { + // Handle batch providers + if (isBatchProvider && provider.calculateMetrics) { + const entityRef = stringifyEntityRef(entity); + const entityKind = normalizeField(entity.kind); + const entityNamespace = normalizeField(entity.metadata.namespace); + const entityOwner = normalizeOwnerRef(entity?.spec?.owner); + + const enabledMetricIds = metricIds.filter( + metricId => + !isMetricIdDisabled(this.config, metricId, entity, logger), + ); + + if (enabledMetricIds.length === 0) { + return undefined; + } + + try { + const resultsMap = await provider.calculateMetrics(entity); + + return enabledMetricIds.map(metricId => { + if (!resultsMap.has(metricId)) { + return { + catalog_entity_ref: entityRef, + metric_id: metricId, + value: undefined, + timestamp: new Date(), + error_message: `calculateMetrics() did not return an entry for metric '${metricId}'`, + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate; + } + + const value = resultsMap.get(metricId) as MetricValue; + + try { + const thresholds = mergeEntityAndProviderThresholds( + entity, + provider, + ); + + const status = + this.thresholdEvaluator.getFirstMatchingThreshold( + value, + metricType, + thresholds, + ); + + return { + catalog_entity_ref: entityRef, + metric_id: metricId, + value, + timestamp: new Date(), + status, + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate; + } catch (error) { + return { + catalog_entity_ref: entityRef, + metric_id: metricId, + value, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate; + } + }); + } catch (error) { + return enabledMetricIds.map( + metricId => + ({ + catalog_entity_ref: entityRef, + metric_id: metricId, + value: undefined, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate), + ); + } + } + let value: MetricValue | undefined; try { @@ -197,12 +289,24 @@ export class PullMetricsByProviderTask implements SchedulerTask { ).then(promises => promises.reduce((acc, curr) => { if (curr.status === 'fulfilled' && curr.value !== undefined) { - return [...acc, curr.value]; + // Batch providers return an array of results, single providers return one result + const result = curr.value; + if (Array.isArray(result)) { + return [...acc, ...result]; + } + return [...acc, result]; } return acc; }, [] as DbMetricValueCreate[]), ); + if (batchResults.length > 0) { + const errorCount = batchResults.filter(r => r.error_message).length; + logger.debug( + `Storing ${batchResults.length} metric values (${errorCount} errors)`, + ); + } + await this.database.createMetricValues(batchResults); totalProcessed += entitiesResponse.items.length; } while (cursor !== undefined); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index d7c7150ab2..6d48b59f66 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -20,7 +20,11 @@ import { mockServices } from '@backstage/backend-test-utils'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { CatalogMetricService } from './CatalogMetricService'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; -import { MockNumberProvider } from '../../__fixtures__/mockProviders'; +import { + MockNumberProvider, + filecheckBatchProvider, + filecheckBatchMetrics, +} from '../../__fixtures__/mockProviders'; import { buildMockDatabaseMetricValues, mockDatabaseMetricValues, @@ -180,6 +184,11 @@ describe('CatalogMetricService', () => { mockedRegistry.getProvider.mockImplementation(id => id === 'github.important_metric' ? provider : secondProvider, ); + mockedRegistry.getMetric.mockImplementation(id => + id === 'github.important_metric' + ? provider.getMetric() + : secondProvider.getMetric(), + ); const multipleMetrics = [ { ...latestEntityMetric[0], metric_id: 'github.important_metric' }, @@ -294,14 +303,14 @@ describe('CatalogMetricService', () => { ); }); - it('should get provider metric', async () => { - const getMetricSpy = jest.spyOn(provider, 'getMetric'); - + it('should get metric from registry', async () => { await service.getLatestEntityMetrics('component:default/test-component', [ 'github.important_metric', ]); - expect(getMetricSpy).toHaveBeenCalled(); + expect(mockedRegistry.getMetric).toHaveBeenCalledWith( + 'github.important_metric', + ); }); it('should merge entity and provider thresholds', async () => { @@ -444,6 +453,82 @@ describe('CatalogMetricService', () => { }); }); + describe('getLatestEntityMetrics with batch providers', () => { + it('should return correct per-metric metadata for batch provider metrics', async () => { + const batchMetricsList = filecheckBatchMetrics.map(m => ({ + id: m.id, + })) as Metric[]; + + mockedRegistry = buildMockMetricProvidersRegistry({ + provider: filecheckBatchProvider, + metricsList: batchMetricsList, + }); + + mockedRegistry.getProvider.mockReturnValue(filecheckBatchProvider); + mockedRegistry.getMetric.mockImplementation((metricId: string) => { + const found = filecheckBatchMetrics.find(m => m.id === metricId); + if (!found) throw new Error(`Metric ${metricId} not found`); + return found; + }); + + const batchDbResults = [ + { + id: 1, + catalog_entity_ref: 'component:default/test-component', + metric_id: 'filecheck.readme', + value: true, + timestamp: new Date('2024-01-15T12:00:00.000Z'), + error_message: null, + status: 'success', + }, + { + id: 2, + catalog_entity_ref: 'component:default/test-component', + metric_id: 'filecheck.license', + value: false, + timestamp: new Date('2024-01-15T12:00:00.000Z'), + error_message: null, + status: 'success', + }, + ] as DbMetricValue[]; + + mockedDatabase = buildMockDatabaseMetricValues({ + latestEntityMetric: batchDbResults, + }); + + (permissionUtils.filterAuthorizedMetrics as jest.Mock).mockReturnValue( + batchMetricsList, + ); + + service = new CatalogMetricService({ + catalog: mockedCatalog, + auth: mockedAuth, + registry: mockedRegistry, + database: mockedDatabase, + logger: mockedLogger, + config: mockServices.rootConfig({ data: {} }), + }); + + const results = await service.getLatestEntityMetrics( + 'component:default/test-component', + ); + + expect(results).toHaveLength(2); + + expect(results[0].id).toBe('filecheck.readme'); + expect(results[0].metadata.title).toBe('File: README.md'); + expect(results[0].metadata.description).toBe( + 'Checks if README.md exists.', + ); + expect(results[0].metadata.type).toBe('boolean'); + + expect(results[1].id).toBe('filecheck.license'); + expect(results[1].metadata.title).toBe('File: LICENSE'); + expect(results[1].metadata.description).toBe('Checks if LICENSE exists.'); + expect(results[1].metadata.type).toBe('boolean'); + }); + }); + describe('getAggregatedMetricByEntityRefs', () => { describe('when entities are provided', () => { let result: AggregatedMetric; diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts index 8d7d95bedb..bfbc465230 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -80,17 +80,17 @@ export class CatalogMetricService { } /** - * Get latest metric results for a specific catalog entity and metric providers. + * Get latest metric results for a specific catalog entity. * * @param entityRef - Entity reference in format "kind:namespace/name" - * @param providerIds - Optional array of provider IDs to get latest metrics of. - * If not provided, gets all available latest metrics. + * @param metricIds - Optional array of metric IDs to get latest metrics of. + * If not provided, gets all available latest metrics. * @param filter - Permission filter * @returns Metric results with entity-specific thresholds applied */ async getLatestEntityMetrics( entityRef: string, - providerIds?: string[], + metricIds?: string[], filter?: PermissionCriteria< PermissionCondition >, @@ -102,7 +102,7 @@ export class CatalogMetricService { throw new NotFoundError(`Entity not found: ${entityRef}`); } - const metricsToFetch = this.registry.listMetrics(providerIds); + const metricsToFetch = this.registry.listMetrics(metricIds); const authorizedMetricsToFetch = filterAuthorizedMetrics( metricsToFetch, @@ -119,7 +119,7 @@ export class CatalogMetricService { let thresholdError: string | undefined; const provider = this.registry.getProvider(metric_id); - const metric = provider.getMetric(); + const metric = this.registry.getMetric(metric_id); try { thresholds = mergeEntityAndProviderThresholds(entity, provider); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 624972536c..3deeabebfd 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -27,6 +27,7 @@ import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; import { MockNumberProvider, MockBooleanProvider, + MockBatchBooleanProvider, githubNumberMetricMetadata, } from '../../__fixtures__/mockProviders'; import { @@ -639,7 +640,9 @@ describe('createRouter', () => { expect(result.statusCode).toBe(404); expect(result.body.error.name).toBe('NotFoundError'); - expect(result.body.error.message).toContain('Metric provider with ID'); + expect(result.body.error.message).toContain( + 'No metric provider registered', + ); }); it('should return aggregated metrics for a specific metric', async () => { @@ -779,6 +782,44 @@ describe('createRouter', () => { mockAggregatedMetric, ); }); + + it('should use registry.getMetric to resolve the correct metric for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + 'filecheck', + 'filecheck', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + ], + ); + metricProvidersRegistry.register(batchProvider); + + const batchAggregationRouter = await createRouter({ + metricProvidersRegistry, + catalogMetricService, + catalog: mockCatalog, + httpAuth: httpAuthMock, + permissions: permissionsMock, + logger: mockServices.logger.mock(), + }); + const batchApp = express(); + batchApp.use(batchAggregationRouter); + batchApp.use(mockErrorHandler()); + + const response = await request(batchApp).get( + '/metrics/filecheck.license/catalog/aggregations', + ); + + expect(response.status).toBe(200); + expect(toAggregatedMetricResultSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'filecheck.license', + title: 'File: LICENSE', + }), + batchProvider.getMetricThresholds(), + mockAggregatedMetric, + ); + }); }); describe('GET /aggregations/:aggregationId', () => { @@ -944,6 +985,41 @@ describe('createRouter', () => { expect(response.body.error.name).toBe('AuthenticationError'); }); + it('should resolve the correct metric for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + 'filecheck', + 'filecheck', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + ], + ); + metricRegistry.register(batchProvider); + + const batchRouter = await createRouter({ + metricProvidersRegistry: metricRegistry, + catalogMetricService: mockCatalogMetricService, + catalog: mockCatalog, + httpAuth: httpAuthMock, + permissions: permissionsMock, + logger: mockServices.logger.mock(), + }); + const batchApp = express(); + batchApp.use(batchRouter); + batchApp.use(mockErrorHandler()); + + const response = await request(batchApp).get( + '/aggregations/filecheck.license', + ); + + expect(response.status).toBe(200); + expect(getAggregatedSpy).toHaveBeenCalledWith( + ['component:default/my-service', 'component:default/my-other-service'], + 'filecheck.license', + aggregationTypes.statusGrouped, + ); + }); + it('should use KPI config metricId and type when aggregationId is a KPI key', async () => { const kpiService = new CatalogMetricService({ catalog: mockCatalog, @@ -1061,6 +1137,37 @@ describe('createRouter', () => { }); }); + it('should resolve the correct metric metadata for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + 'filecheck', + 'filecheck', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + ], + ); + metaRegistry.register(batchProvider); + + const router = await createRouter({ + metricProvidersRegistry: metaRegistry, + catalogMetricService: metaCatalogMetricService, + catalog: metaCatalog, + httpAuth: httpAuthMock, + permissions: permissionsMock, + logger: mockServices.logger.mock(), + }); + const batchMetaApp = express(); + batchMetaApp.use(router); + batchMetaApp.use(mockErrorHandler()); + + const response = await request(batchMetaApp).get( + '/aggregations/filecheck.license/metadata', + ); + + expect(response.status).toBe(200); + expect(response.body.title).toBe('File: LICENSE'); + }); + it('returns metadata for metric id when no KPI row exists', async () => { const svc = new CatalogMetricService({ catalog: metaCatalog, diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts index 669abc8be0..ce8065f3fe 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -152,7 +152,7 @@ export async function createRouter({ ); const provider = metricProvidersRegistry.getProvider(metricId); - const metric = provider.getMetric(); + const metric = metricProvidersRegistry.getMetric(metricId); const authorizedMetrics = filterAuthorizedMetrics([metric], conditions); if (authorizedMetrics.length === 0) { @@ -282,7 +282,9 @@ export async function createRouter({ const provider = metricProvidersRegistry.getProvider( aggregationConfig?.metricId ?? aggregationId, ); - const metric = provider.getMetric(); + const metric = metricProvidersRegistry.getMetric( + aggregationConfig?.metricId ?? aggregationId, + ); const entitiesOwnedByAUser = await getEntitiesOwnedByUser(userEntityRef, { catalog, @@ -329,10 +331,9 @@ export async function createRouter({ aggregationId, ]); - const provider = metricProvidersRegistry.getProvider( + const metric = metricProvidersRegistry.getMetric( aggregationConfig?.metricId ?? aggregationId, ); - const metric = provider.getMetric(); res.json( AggregatedMetricMapper.toAggregationMetadata(metric, aggregationConfig), diff --git a/workspaces/scorecard/plugins/scorecard-node/report.api.md b/workspaces/scorecard/plugins/scorecard-node/report.api.md index 6fb73a54bd..f592df3706 100644 --- a/workspaces/scorecard/plugins/scorecard-node/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-node/report.api.md @@ -32,8 +32,11 @@ export function getThresholdsFromConfig( // @public export interface MetricProvider { calculateMetric(entity: Entity): Promise>; + calculateMetrics?(entity: Entity): Promise>>; getCatalogFilter(): Record; getMetric(): Metric; + getMetricIds?(): string[]; + getMetrics?(): Metric[]; getMetricThresholds(): ThresholdConfig; getMetricType(): T; getProviderDatasourceId(): string; diff --git a/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts b/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts index d572304bc7..9d9385628e 100644 --- a/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts @@ -62,4 +62,28 @@ export interface MetricProvider { * @public */ getCatalogFilter(): Record; + + /** + * Get all metric IDs this provider handles. + * For batch providers that handle multiple metrics. + * Defaults to [getProviderId()] if not implemented. + * @public + */ + getMetricIds?(): string[]; + + /** + * Get all metrics this provider exposes. + * For batch providers that handle multiple metrics. + * Defaults to [getMetric()] if not implemented. + * @public + */ + getMetrics?(): Metric[]; + + /** + * Calculate multiple metrics in a single call. + * For batch providers that can efficiently compute multiple metrics together. + * Defaults to [calculateMetric()] ff not implemented. + * @public + */ + calculateMetrics?(entity: Entity): Promise>>; } diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index ba2019a066..7d268254f2 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -169,12 +169,16 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.filecheck.title': string; + readonly 'metric.filecheck.description': string; readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.exist': string; + readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 4c0d093e0b..2b82268d27 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -69,12 +69,16 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.filecheck.title': string; + readonly 'metric.filecheck.description': string; readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.exist': string; + readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index 27a62c0291..d6d03d58e5 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -47,6 +47,18 @@ function AggregatedCardWithGithubOpenPrsContent() { return ; } +function AggregatedCardWithGithubFilecheckLicenseContent() { + return ( + + ); +} + +function AggregatedCardWithGithubFilecheckCodeownersContent() { + return ( + + ); +} + function BorderlessHomeWidgetRenderer({ Content }: RendererProps) { return ; } @@ -134,3 +146,45 @@ export const aggregatedCardWithGithubOpenPrsWidget = }), }, }); + +/** + * NFS widget: AggregatedCardWithGithubFilecheckLicense. + * @alpha + */ +export const aggregatedCardWithGithubFilecheckLicenseWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-filecheck-license', + params: { + name: 'AggregatedCardWithGithubFilecheckLicense', + title: 'Scorecard: LICENSE file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilecheckLicenseContent, + }), + }, + }); + +/** + * NFS widget: AggregatedCardWithGithubFilecheckCodeowners. + * @alpha + */ +export const aggregatedCardWithGithubFilecheckCodeownersWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-filecheck-codeowners', + params: { + name: 'AggregatedCardWithGithubFilecheckCodeowners', + title: 'Scorecard: CODEOWNERS file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilecheckCodeownersContent, + }), + }, + }); diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx index cdda2a9fc8..59766fc380 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx @@ -28,6 +28,8 @@ import { aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithGithubOpenPrsWidget, aggregatedCardWithJiraOpenIssuesWidget, + aggregatedCardWithGithubFilecheckLicenseWidget, + aggregatedCardWithGithubFilecheckCodeownersWidget, } from './extensions/homePageCards'; import { scorecardPage } from './extensions/scorecardPage'; @@ -82,6 +84,8 @@ export const scorecardHomeModule = createFrontendModule({ aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithJiraOpenIssuesWidget, aggregatedCardWithGithubOpenPrsWidget, + aggregatedCardWithGithubFilecheckLicenseWidget, + aggregatedCardWithGithubFilecheckCodeownersWidget, ], }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx index 7435ac1119..8319b94127 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx @@ -99,8 +99,10 @@ const CustomLegend = (props: CustomLegendProps) => { return translated === `thresholds.${ruleKey}` ? ruleKey.charAt(0).toUpperCase() + ruleKey.slice(1) : translated; - })()}{' '} - {ruleExpression && `${ruleExpression}`} + })()} + {ruleExpression && + !/^==(?:true|false)$/.test(ruleExpression) && + ` ${ruleExpression}`} ); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index 18753a1f32..5cbe486b15 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -22,7 +22,7 @@ import Box from '@mui/material/Box'; import NoScorecardsState from '../Common/NoScorecardsState'; import Scorecard from './Scorecard'; import { useScorecards } from '../../hooks/useScorecards'; -import { getStatusConfig } from '../../utils'; +import { getStatusConfig, resolveMetricTranslation } from '../../utils'; import PermissionRequiredState from '../Common/PermissionRequiredState'; import { useTranslation } from '../../hooks/useTranslation'; import { CardLoading } from '../Common/CardLoading'; @@ -69,28 +69,28 @@ export const EntityScorecardContent = () => { thresholdRules: metric.result.thresholdResult.definition?.rules, }); - // Use metric ID to construct translation keys, fallback to original title/description - const titleKey = `metric.${metric.id}.title`; - const descriptionKey = `metric.${metric.id}.description`; - - const title = t(titleKey as any, {}); - const description = t(descriptionKey as any, {}); - - // If translation returns the key itself, fallback to original title/description - const finalTitle = title === titleKey ? metric.metadata.title : title; - const finalDescription = - description === descriptionKey - ? metric.metadata.description - : description; + const title = resolveMetricTranslation( + t, + metric.id, + 'title', + metric.metadata.title, + ); + const description = resolveMetricTranslation( + t, + metric.id, + 'description', + metric.metadata.description, + ); return ( - + - {!isErrorState && ( + {hasDisplayableValue && ( ({ jest.mock('../../../utils', () => ({ getStatusConfig: jest.fn(), + resolveMetricTranslation: jest.fn( + (_t: any, _metricId: string, _field: string, fallback?: string) => + fallback ?? `metric.${_metricId}.${_field}`, + ), })); const useScorecardsMock = useScorecards as jest.Mock; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx index 891f5e5ab0..cd92e9f741 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx @@ -88,6 +88,7 @@ describe('Scorecard Component', () => { statusColor: 'success.main', statusIcon: 'scorecardSuccessStatusIcon', value: 8, + metricType: 'number' as const, thresholds: { status: 'success' as const, definition: { diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx index bc8b7856f6..2f72ab5869 100644 --- a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx @@ -95,4 +95,63 @@ describe('useMetricDisplayLabels', () => { 'Current count of open Pull Requests for a given GitHub repository.', }); }); + + describe('parent key cascading lookup', () => { + const fileCheckMetric = { + id: 'filecheck.readme', + title: 'GitHub File: README.md', + description: 'Checks if README.md exists in the repository.', + }; + + it('should resolve via parent key when exact key has no translation', () => { + mockT.mockImplementation((key: string, params?: { name?: string }) => { + if (key === 'metric.filecheck.title') + return `File Check: ${params?.name}`; + if (key === 'metric.filecheck.description') + return `Checks if ${params?.name} exists`; + return key; + }); + + const { result } = renderHook(() => + useMetricDisplayLabels(fileCheckMetric), + ); + + expect(result.current).toEqual({ + title: 'File Check: readme', + description: 'Checks if readme exists', + }); + }); + + it('should prefer exact key over parent key', () => { + mockT.mockImplementation((key: string, params?: { name?: string }) => { + if (key === 'metric.filecheck.readme.title') + return 'Exact README Title'; + if (key === 'metric.filecheck.title') + return `File Check: ${params?.name}`; + return key; + }); + + const { result } = renderHook(() => + useMetricDisplayLabels(fileCheckMetric), + ); + + expect(result.current).toEqual({ + title: 'Exact README Title', + description: 'Checks if README.md exists in the repository.', + }); + }); + + it('should fall back to original values when neither exact nor parent key has translation', () => { + mockT.mockImplementation((key: string) => key); + + const { result } = renderHook(() => + useMetricDisplayLabels(fileCheckMetric), + ); + + expect(result.current).toEqual({ + title: 'GitHub File: README.md', + description: 'Checks if README.md exists in the repository.', + }); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx index 3cf4967b8e..5a03b91768 100644 --- a/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/hooks/useMetricDisplayLabels.tsx @@ -17,6 +17,7 @@ import { Metric } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { useTranslation } from './useTranslation'; +import { resolveMetricTranslation } from '../utils'; export const useMetricDisplayLabels = ( metric?: Pick, @@ -30,21 +31,13 @@ export const useMetricDisplayLabels = ( return { title: '', description: '' }; } - const { id, title: originalTitle, description: originalDescription } = metric; - - const titleKey = `metric.${id}.title`; - const descriptionKey = `metric.${id}.description`; - - const translatedTitle = t(titleKey as any, {}); - const translatedDescription = t(descriptionKey as any, {}); - - const isTitleTranslated = translatedTitle !== titleKey; - const isDescriptionTranslated = translatedDescription !== descriptionKey; - return { - title: isTitleTranslated ? translatedTitle : originalTitle, - description: isDescriptionTranslated - ? translatedDescription - : originalDescription, + title: resolveMetricTranslation(t, metric.id, 'title', metric.title), + description: resolveMetricTranslation( + t, + metric.id, + 'description', + metric.description, + ), }; }; diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index 23724ec43d..75c0189e75 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -79,6 +79,9 @@ const scorecardTranslationDe = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira offene blockierende Tickets', 'metric.jira.open_issues.description': 'Hervorhebt die Anzahl der kritischen, blockierenden Probleme, die derzeit in Jira offen sind.', + 'metric.filecheck.title': 'Dateiprüfung: {{name}}', + 'metric.filecheck.description': + 'Prüft, ob die Datei {{name}} im Repository vorhanden ist.', 'metric.lastUpdated': 'Zuletzt aktualisiert: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Zuletzt aktualisiert: Nicht verfügbar', 'metric.someEntitiesNotReportingValues': @@ -88,6 +91,8 @@ const scorecardTranslationDe = createTranslationMessages({ 'thresholds.success': 'Erfolg', 'thresholds.warning': 'Warnung', 'thresholds.error': 'Fehler', + 'thresholds.exist': 'Vorhanden', + 'thresholds.missing': 'Fehlend', 'thresholds.noEntities': 'Keine Elemente im {{category}}-Zustand', 'thresholds.entities_one': '{{count}} Element', 'thresholds.entities_other': '{{count}} Elemente', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index e03d8e7c93..f20e6260a0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -80,6 +80,9 @@ const scorecardTranslationEs = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira tickets bloqueantes abiertos', 'metric.jira.open_issues.description': 'Destaca el número de problemas críticos y bloqueantes que están actualmente abiertos en Jira.', + 'metric.filecheck.title': 'Verificación de archivo: {{name}}', + 'metric.filecheck.description': + 'Verifica si el archivo {{name}} existe en el repositorio.', 'metric.lastUpdated': 'Última actualización: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Última actualización: No disponible', 'metric.someEntitiesNotReportingValues': @@ -89,6 +92,8 @@ const scorecardTranslationEs = createTranslationMessages({ 'thresholds.success': 'Éxito', 'thresholds.warning': 'Advertencia', 'thresholds.error': 'Error', + 'thresholds.exist': 'Existe', + 'thresholds.missing': 'Faltante', 'thresholds.noEntities': 'No hay entidades en el estado {{category}}', 'thresholds.entities_one': '{{count}} entidad', 'thresholds.entities_other': '{{count}} entidades', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index c931394391..f75ab5b2b0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -81,6 +81,9 @@ const scorecardTranslationFr = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira ouvre des tickets bloquants', 'metric.jira.open_issues.description': 'Met en évidence le nombre de problèmes critiques et bloquants actuellement ouverts dans Jira.', + 'metric.filecheck.title': 'Vérification de fichier : {{name}}', + 'metric.filecheck.description': + 'Vérifie si le fichier {{name}} existe dans le dépôt.', 'metric.lastUpdated': 'Dernière mise à jour: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Dernière mise à jour: Non disponible', 'metric.someEntitiesNotReportingValues': @@ -90,6 +93,8 @@ const scorecardTranslationFr = createTranslationMessages({ 'thresholds.success': 'Succès', 'thresholds.warning': 'Attention', 'thresholds.error': 'Erreur', + 'thresholds.exist': 'Existant', + 'thresholds.missing': 'Manquant', 'thresholds.noEntities': "Aucune entité dans l'état {{category}}", 'thresholds.entities_one': '{{count}} entité', 'thresholds.entities_other': '{{count}} entités', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 947d047a59..a8c443df76 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -81,6 +81,9 @@ const scorecardTranslationIt = createTranslationMessages({ 'metric.jira.open_issues.title': 'Ticket di blocco Jira aperti', 'metric.jira.open_issues.description': 'Evidenzia il numero di problemi critici e di blocco attualmente aperti in Jira.', + 'metric.filecheck.title': 'Verifica file: {{name}}', + 'metric.filecheck.description': + 'Verifica se il file {{name}} esiste nel repository.', 'metric.lastUpdated': 'Ultimo aggiornamento: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Ultimo aggiornamento: Non disponibile', 'metric.someEntitiesNotReportingValues': @@ -90,6 +93,8 @@ const scorecardTranslationIt = createTranslationMessages({ 'thresholds.success': 'Attività riuscita', 'thresholds.warning': 'Avviso', 'thresholds.error': 'Errore', + 'thresholds.exist': 'Esistente', + 'thresholds.missing': 'Mancante', 'thresholds.noEntities': 'Nessuna entità con stato {{category}}', 'thresholds.entities_one': '{{count}} entità', 'thresholds.entities_other': '{{count}} entità', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index fa414ce5cf..bf039fd261 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -81,6 +81,9 @@ const scorecardTranslationJa = createTranslationMessages({ 'Jira のオープン状態の進行を妨げているチケット', 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', + 'metric.filecheck.title': 'ファイル確認: {{name}}', + 'metric.filecheck.description': + 'リポジトリーに {{name}} ファイルが存在するかを確認します。', 'metric.lastUpdated': '最終更新日: {{timestamp}}', 'metric.lastUpdatedNotAvailable': '最終更新日: 利用不可', 'metric.someEntitiesNotReportingValues': @@ -90,6 +93,8 @@ const scorecardTranslationJa = createTranslationMessages({ 'thresholds.success': '成功', 'thresholds.warning': '警告', 'thresholds.error': 'エラー', + 'thresholds.exist': '存在', + 'thresholds.missing': '欠落', 'thresholds.noEntities': '{{category}} 状態のエンティティーがありません', 'thresholds.entities_one': '{{count}} エンティティー', 'thresholds.entities_other': '{{count}} エンティティー', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index e31ab776f3..d04cd3396d 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -87,6 +87,10 @@ export const scorecardMessages = { description: 'Highlights the number of critical, blocking issues that are currently open in Jira.', }, + filecheck: { + title: 'File check: {{name}}', + description: 'Checks whether the {{name}} file exists in the repository.', + }, lastUpdated: 'Last updated: {{timestamp}}', lastUpdatedNotAvailable: 'Last updated: Not available', someEntitiesNotReportingValues: @@ -98,6 +102,8 @@ export const scorecardMessages = { success: 'Success', warning: 'Warning', error: 'Error', + exist: 'Exist', + missing: 'Missing', noEntities: 'No entities in {{category}} state', entities_one: '{{count}} entity', entities_other: '{{count}} entities', diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx index 49c083d5e5..2c8736fe21 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx @@ -15,7 +15,11 @@ */ import type { Theme } from '@mui/material/styles'; -import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { + DEFAULT_NUMBER_THRESHOLDS, + ScorecardThresholdRuleColors, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { getStatusConfig, resolveStatusColor, @@ -170,6 +174,60 @@ describe('statusUtils', () => { icon: 'scorecardSuccessStatusIcon', }); }); + + it('should return icon for missing evaluation with boolean threshold rules', () => { + const result = getStatusConfig({ + evaluation: 'missing', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }); + + expect(result).toEqual({ + color: 'error.main', + icon: 'scorecardErrorStatusIcon', + }); + }); + + it('should return icon for exist evaluation with boolean threshold rules', () => { + const result = getStatusConfig({ + evaluation: 'exist', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }); + + expect(result).toEqual({ + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }); + }); }); describe('optional parameters', () => { diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts new file mode 100644 index 0000000000..17f4d5a8a4 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts @@ -0,0 +1,176 @@ +/* + * 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 { resolveMetricTranslation } from '../translationUtils'; + +type MockT = (key: string, params?: Record) => string; + +const createMockT = (translations: Record): MockT => { + return (key: string, params?: Record) => { + let value = translations[key]; + if (value === undefined) return key; + if (params) { + for (const [k, v] of Object.entries(params)) { + value = value.replace(`{{${k}}}`, v); + } + } + return value; + }; +}; + +describe('resolveMetricTranslation', () => { + it('returns exact translation when key exists', () => { + const t = createMockT({ + 'metric.github.open_prs.title': 'GitHub open PRs', + }); + + expect(resolveMetricTranslation(t as any, 'github.open_prs', 'title')).toBe( + 'GitHub open PRs', + ); + }); + + it('returns parent translation with name param for 2-segment metric IDs', () => { + const t = createMockT({ + 'metric.filecheck.title': 'File check: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'filecheck.readme', 'title'), + ).toBe('File check: readme'); + }); + + it('returns parent translation for description field', () => { + const t = createMockT({ + 'metric.filecheck.description': + 'Checks whether the {{name}} file exists in the repository.', + }); + + expect( + resolveMetricTranslation(t as any, 'filecheck.readme', 'description'), + ).toBe('Checks whether the readme file exists in the repository.'); + }); + + it('prefers exact match over parent match', () => { + const t = createMockT({ + 'metric.filecheck.readme.title': 'README file check', + 'metric.filecheck.title': 'File check: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'filecheck.readme', 'title'), + ).toBe('README file check'); + }); + + it('uses first segment as template namespace with the rest as name', () => { + const t = createMockT({ + 'metric.some.title': 'Provider: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'some.provider.deep.nested', 'title'), + ).toBe('Provider: provider.deep.nested'); + }); + + it('returns raw key when no translation matches for 2-segment metric ID', () => { + const t = createMockT({}); + + expect(resolveMetricTranslation(t as any, 'unknown.metric', 'title')).toBe( + 'metric.unknown.metric.title', + ); + }); + + it('returns raw key when neither exact nor parent translation matches', () => { + const t = createMockT({}); + + expect( + resolveMetricTranslation(t as any, 'unknown.metric.instance', 'title'), + ).toBe('metric.unknown.metric.instance.title'); + }); + + it('attempts parent lookup for 2-segment metric IDs', () => { + const t = createMockT({ + 'metric.unknown.title': 'Resolved via parent: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'unknown.something', 'title'), + ).toBe('Resolved via parent: something'); + }); + + it('does not attempt parent lookup for 1-segment metric IDs', () => { + const t = createMockT({}); + + expect(resolveMetricTranslation(t as any, 'single', 'title')).toBe( + 'metric.single.title', + ); + }); + + it('returns fallback when no translation matches and fallback is provided', () => { + const t = createMockT({}); + + expect( + resolveMetricTranslation( + t as any, + 'unknown.metric', + 'title', + 'API Title', + ), + ).toBe('API Title'); + }); + + it('returns fallback for 3-segment ID when neither exact nor parent matches', () => { + const t = createMockT({}); + + expect( + resolveMetricTranslation( + t as any, + 'unknown.metric.instance', + 'description', + 'API description text', + ), + ).toBe('API description text'); + }); + + it('prefers translation over fallback when translation exists', () => { + const t = createMockT({ + 'metric.github.open_prs.title': 'GitHub open PRs', + }); + + expect( + resolveMetricTranslation( + t as any, + 'github.open_prs', + 'title', + 'Fallback Title', + ), + ).toBe('GitHub open PRs'); + }); + + it('prefers parent translation over fallback', () => { + const t = createMockT({ + 'metric.filecheck.title': 'File check: {{name}}', + }); + + expect( + resolveMetricTranslation( + t as any, + 'filecheck.readme', + 'title', + 'Fallback Title', + ), + ).toBe('File check: readme'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index 50f6c6c6e7..84ba13a5f1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -25,3 +25,4 @@ export { export { getLastUpdatedLabel } from './entityTableUtils'; export { getStatusConfig, resolveStatusColor } from './statusUtils'; export { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; +export { resolveMetricTranslation } from './translationUtils'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts new file mode 100644 index 0000000000..fade4f125f --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts @@ -0,0 +1,54 @@ +/* + * 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 { TranslationFunction } from '@backstage/core-plugin-api/alpha'; +import { scorecardTranslationRef } from '../translations'; + +type ScorecardTranslationFunction = TranslationFunction< + typeof scorecardTranslationRef.T +>; + +/** + * Resolves a metric's translated title or description using a cascading lookup: + * + * 1. Exact key: metric.. (e.g. metric.github.open_prs.title) + * 2. Template key: metric.. with name = + * The first dot-separated segment is always the provider/namespace. + * E.g. filecheck.codeowners -> metric.filecheck.title with name = codeowners + * 3. Falls back to `fallback` when provided, otherwise returns the raw + * translation key. + */ +export function resolveMetricTranslation( + t: ScorecardTranslationFunction, + metricId: string, + field: 'title' | 'description', + fallback?: string, +): string { + const key = `metric.${metricId}.${field}`; + const translated = t(key as any, {}); + if (translated !== key) return translated; + + const dotIndex = metricId.indexOf('.'); + if (dotIndex !== -1) { + const base = metricId.slice(0, dotIndex); + const name = metricId.slice(dotIndex + 1); + const templateKey = `metric.${base}.${field}`; + const templateTranslated = t(templateKey as any, { name }); + if (templateTranslated !== templateKey) return templateTranslated; + } + + return fallback ?? translated; +} diff --git a/workspaces/scorecard/yarn.lock b/workspaces/scorecard/yarn.lock index 3d70362e40..e5b4b2aa7c 100644 --- a/workspaces/scorecard/yarn.lock +++ b/workspaces/scorecard/yarn.lock @@ -11028,6 +11028,23 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:plugins/scorecard-backend-module-filecheck": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck@workspace:plugins/scorecard-backend-module-filecheck" + 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" + "@backstage/config": "npm:^1.3.6" + "@backstage/errors": "npm:^1.2.7" + "@backstage/integration": "npm:^2.0.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-github@workspace:^, @red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github@workspace:plugins/scorecard-backend-module-github": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github@workspace:plugins/scorecard-backend-module-github" @@ -16516,6 +16533,7 @@ __metadata: "@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-dependabot": "workspace:^" + "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira": "workspace:^" "@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf": "workspace:^"