From 07c5289eb32310802e80478b8906f368fe8cd77e Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 24 Feb 2026 14:42:31 +0700 Subject: [PATCH 01/20] feat: implement batch provider for file checks Signed-off-by: Diana Janickova test: implement and update unit tests Assisted-By: Cursor Signed-off-by: Diana Janickova chore: generate api reports Signed-off-by: Diana Janickova ref: use metricId instead of providerId Signed-off-by: Diana Janickova ref: Apply suggestions from code review Co-authored-by: Ihor Mykhno fix: run prettier Signed-off-by: Diana Janickova fix: update expected string in test Signed-off-by: Diana Janickova --- .../scorecard-backend-module-github/README.md | 55 ++++ .../config.d.ts | 8 + .../src/github/GitHubClient.test.ts | 79 ++++++ .../src/github/GithubClient.ts | 52 ++++ .../src/github/types.ts | 15 + .../GithubFilesProvider.test.ts | 265 ++++++++++++++++++ .../metricProviders/GithubFilesProvider.ts | 131 +++++++++ .../src/module.ts | 5 + .../mockMetricProvidersRegistry.ts | 10 + .../__fixtures__/mockProviders.ts | 103 +++++++ .../providers/MetricProvidersRegistry.test.ts | 217 +++++++++++++- .../src/providers/MetricProvidersRegistry.ts | 152 ++++++---- .../tasks/PullMetricsByProviderTask.test.ts | 187 +++++++++++- .../tasks/PullMetricsByProviderTask.ts | 81 +++++- .../src/service/CatalogMetricService.test.ts | 93 +++++- .../src/service/CatalogMetricService.ts | 12 +- .../src/service/router.test.ts | 4 +- .../plugins/scorecard-common/report.api.md | 3 + .../scorecard-common/src/types/threshold.ts | 11 + .../plugins/scorecard-node/report.api.md | 3 + .../scorecard-node/src/api/MetricProvider.ts | 24 ++ 21 files changed, 1427 insertions(+), 83 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md b/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md index 9a657d6da1..da38d2ac4b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/README.md @@ -85,6 +85,51 @@ This metric counts all pull requests that are currently in an "open" state for t expression: '<10' ``` +### GitHub File Checks (`github.files_check.*`) + +This metric provider checks whether specific files exist in a repository. It's useful for enforcing best practices like having a `README.md`, `LICENSE`, `CODEOWNERS`, or other required files. + +- **Metric ID**: `github.files_check.` (e.g., `github.files_check.readme`) +- **Type**: Boolean +- **Datasource**: `github` +- **Default thresholds**: + - `success`: File exists (`==true`) + - `error`: File is missing (`==false`) + +#### Configuration + +To enable file checks, add a `files_check` configuration in your `app-config.yaml`: + +```yaml +# app-config.yaml +scorecard: + plugins: + github: + files_check: + files: + - readme: 'README.md' + - license: 'LICENSE' + - codeowners: 'CODEOWNERS' + - dockerfile: 'Dockerfile' +``` + +Each entry in the `files` array creates a separate metric: + +- The **key** (e.g., `readme`) becomes the metric identifier suffix (`github.files_check.readme`) +- The **value** (e.g., `README.md`) is the file path to check in the repository + +#### File Path Format + +File paths must be relative to the repository root: + +| Format | Example | Valid | +| ---------------- | ---------------------- | ----- | +| Root file | `README.md` | ✅ | +| Subdirectory | `docs/CONTRIBUTING.md` | ✅ | +| Hidden file | `.gitignore` | ✅ | +| With `./` prefix | `./README.md` | ❌ | +| Absolute path | `/home/file.txt` | ❌ | + ## Configuration ### Threshold Configuration @@ -107,6 +152,16 @@ scorecard: minutes: 5 initialDelay: seconds: 5 + files_check: + files: + - readme: 'README.md' + 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). diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/config.d.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/config.d.ts index 2bd3152e22..f4eef79cfa 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/config.d.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/config.d.ts @@ -32,6 +32,14 @@ export interface Config { }; schedule?: SchedulerServiceTaskScheduleDefinitionConfig; }; + files_check?: { + /** File existence checks configuration */ + files?: Array<{ + /** Key is the metric identifier, value is the file path */ + [metricId: string]: string; + }>; + schedule?: SchedulerServiceTaskScheduleDefinitionConfig; + }; }; }; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts index 82b6cdfc13..376ab5d376 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts @@ -93,4 +93,83 @@ describe('GithubClient', () => { ).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`); }); }); + + describe('checkFilesExist', () => { + it('should return true for files that exist and false for those that do not', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map([ + ['github.files_check.readme', 'README.md'], + ['github.files_check.license', 'LICENSE'], + ['github.files_check.codeowners', 'CODEOWNERS'], + ]); + + const response = { + repository: { + github_files_check_readme: { id: 'abc123' }, + github_files_check_license: null, + github_files_check_codeowners: { id: 'def456' }, + }, + }; + mockedGraphqlClient.mockResolvedValue(response); + + const result = await githubClient.checkFilesExist(url, repository, files); + + expect(result.get('github.files_check.readme')).toBe(true); + expect(result.get('github.files_check.license')).toBe(false); + expect(result.get('github.files_check.codeowners')).toBe(true); + expect(mockedGraphqlClient).toHaveBeenCalledTimes(1); + expect(mockedGraphqlClient).toHaveBeenCalledWith( + expect.stringContaining('query checkFilesExist'), + { owner: 'owner', repo: 'repo' }, + ); + expect(getCredentialsSpy).toHaveBeenCalledWith({ url }); + }); + + it('should sanitize metric IDs with special characters to valid GraphQL aliases', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map([ + ['github.files_check.my-file', 'my-file.txt'], + ]); + + const response = { + repository: { + github_files_check_my_file: { id: 'xyz789' }, + }, + }; + mockedGraphqlClient.mockResolvedValue(response); + + const result = await githubClient.checkFilesExist(url, repository, files); + + expect(result.get('github.files_check.my-file')).toBe(true); + expect(mockedGraphqlClient).toHaveBeenCalledWith( + expect.stringContaining('github_files_check_my_file'), + expect.any(Object), + ); + }); + + it('should return an empty map when no files are provided', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map(); + + const response = { + repository: {}, + }; + mockedGraphqlClient.mockResolvedValue(response); + + const result = await githubClient.checkFilesExist(url, repository, files); + + expect(result.size).toBe(0); + }); + + it('should throw error when GitHub integration for URL is missing', async () => { + const unknownUrl = 'https://unknown-host/owner/repo'; + const files = new Map([ + ['github.files_check.readme', 'README.md'], + ]); + + await expect( + githubClient.checkFilesExist(unknownUrl, repository, files), + ).rejects.toThrow(`Missing GitHub integration for '${unknownUrl}'`); + }); + }); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index 20f9fd2f66..6bf9803840 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -77,4 +77,56 @@ export class GithubClient { return response.repository.pullRequests.totalCount; } + + /** + * Sanitize a string to be a valid GraphQL alias. + * eg. "github.files_check.readme-correct" -> "github_files_check_readme_correct" + */ + private sanitizeGraphQLAlias(alias: string): string { + return alias.replace(/[^_0-9A-Za-z]/g, '_'); + } + + async checkFilesExist( + url: string, + repository: GithubRepository, + files: Map, + ): Promise> { + const octokit = await this.getOctokitClient(url); + + const aliasToMetricId = new Map(); + const fileChecksParts: string[] = []; + + for (const [metricId, path] of files) { + const sanitizedAlias = this.sanitizeGraphQLAlias(metricId); + + aliasToMetricId.set(sanitizedAlias, metricId); + fileChecksParts.push( + `${sanitizedAlias}: object(expression: "HEAD:${path}") { id }`, + ); + } + + const fileChecks = fileChecksParts.join('\n'); + + const query = ` + query checkFilesExist($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${fileChecks} + } + } + `; + + const response = await octokit<{ + repository: Record; + }>(query, { + owner: repository.owner, + repo: repository.repo, + }); + + // Map results back to original metric IDs + const results = new Map(); + for (const [sanitizedAlias, metricId] of aliasToMetricId) { + results.set(metricId, response.repository[sanitizedAlias] !== null); + } + return results; + } } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts index df73a37e94..974c5822bb 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/types.ts @@ -17,3 +17,18 @@ export type GithubRepository = { owner: string; repo: string; }; + +/** + * Single file to check + */ +export type GithubFile = { + id: string; + path: string; +}; + +/** + * Configuration for a file existence check + */ +export type GithubFilesConfig = { + files: GithubFile[]; +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts new file mode 100644 index 0000000000..da380fa6fd --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -0,0 +1,265 @@ +/* + * 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 { GithubFilesProvider } from './GithubFilesProvider'; +import { GithubClient } from '../github/GithubClient'; +import { DEFAULT_BOOLEAN_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +jest.mock('@backstage/catalog-model', () => ({ + ...jest.requireActual('@backstage/catalog-model'), + getEntitySourceLocation: jest.fn().mockReturnValue({ + type: 'url', + target: 'https://github.com/org/orgRepo/tree/main/', + }), +})); +jest.mock('../github/GithubClient'); + +describe('GithubFilesProvider', () => { + describe('fromConfig', () => { + it('should return undefined when no files configuration is provided', () => { + const provider = GithubFilesProvider.fromConfig(new ConfigReader({})); + + expect(provider).toBeUndefined(); + }); + + it('should return undefined when files array is empty', () => { + const provider = GithubFilesProvider.fromConfig( + new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [], + }, + }, + }, + }, + }), + ); + + expect(provider).toBeUndefined(); + }); + + it('should create provider with files configuration', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [{ readme: 'README.md' }, { license: 'LICENSE' }], + }, + }, + }, + }, + }); + + const provider = GithubFilesProvider.fromConfig(config); + + expect(provider).toBeDefined(); + expect(provider!.getMetricIds()).toEqual([ + 'github.files_check.readme', + 'github.files_check.license', + ]); + }); + + it('should throw error when file config entry has multiple key-value pairs', () => { + const invalidConfig = new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [{ readme: 'README.md', license: 'LICENSE' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(invalidConfig)).toThrow( + 'Each file config entry must have exactly one key-value pair', + ); + }); + }); + + describe('provider methods', () => { + let provider: GithubFilesProvider; + + beforeEach(() => { + const config = new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [ + { readme: 'README.md' }, + { codeowners: 'CODEOWNERS' }, + { dockerfile: 'Dockerfile' }, + ], + }, + }, + }, + }, + }); + provider = GithubFilesProvider.fromConfig(config)!; + }); + + it('should return correct provider ID', () => { + expect(provider.getProviderId()).toBe('github.files_check'); + }); + + it('should return correct datasource ID', () => { + expect(provider.getProviderDatasourceId()).toBe('github'); + }); + + it('should return correct metric type', () => { + expect(provider.getMetricType()).toBe('boolean'); + }); + + it('should return all metric IDs', () => { + expect(provider.getMetricIds()).toEqual([ + 'github.files_check.readme', + 'github.files_check.codeowners', + 'github.files_check.dockerfile', + ]); + }); + + it('should return default boolean thresholds', () => { + expect(provider.getMetricThresholds()).toEqual( + DEFAULT_BOOLEAN_THRESHOLDS, + ); + }); + + it('should return correct catalog filter', () => { + expect(provider.getCatalogFilter()).toEqual({ + 'metadata.annotations.github.com/project-slug': expect.any(Symbol), + }); + }); + + it('should return all metrics with correct metadata', () => { + const metrics = provider.getMetrics(); + + expect(metrics).toHaveLength(3); + expect(metrics[0]).toEqual({ + id: 'github.files_check.readme', + title: 'GitHub File: README.md', + description: 'Checks if README.md exists in the repository.', + type: 'boolean', + history: true, + }); + expect(metrics[1]).toEqual({ + id: 'github.files_check.codeowners', + title: 'GitHub 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: 'github.files_check.readme', + title: 'GitHub File: README.md', + description: 'Checks if README.md exists in the repository.', + type: 'boolean', + history: true, + }); + }); + }); + + describe('calculateMetrics', () => { + let provider: GithubFilesProvider; + const mockedGithubClient = GithubClient as jest.MockedClass< + typeof GithubClient + >; + const mockedGithubClientInstance = { + checkFilesExist: jest.fn(), + } as any; + mockedGithubClient.mockImplementation(() => mockedGithubClientInstance); + + const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'github.com/project-slug': 'org/orgRepo', + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + const config = new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [{ readme: 'README.md' }, { license: 'LICENSE' }], + }, + }, + }, + }, + }); + provider = GithubFilesProvider.fromConfig(config)!; + }); + + it('should calculate metrics for all configured files', async () => { + const mockResults = new Map([ + ['github.files_check.readme', true], + ['github.files_check.license', false], + ]); + mockedGithubClientInstance.checkFilesExist.mockResolvedValue(mockResults); + + const result = await provider.calculateMetrics(mockEntity); + + expect(result.get('github.files_check.readme')).toBe(true); + expect(result.get('github.files_check.license')).toBe(false); + expect(mockedGithubClientInstance.checkFilesExist).toHaveBeenCalledWith( + 'https://github.com/org/orgRepo/tree/main/', + { owner: 'org', repo: 'orgRepo' }, + new Map([ + ['github.files_check.readme', 'README.md'], + ['github.files_check.license', 'LICENSE'], + ]), + ); + }); + + it('should return first metric result for legacy calculateMetric()', async () => { + const mockResults = new Map([ + ['github.files_check.readme', true], + ['github.files_check.license', false], + ]); + mockedGithubClientInstance.checkFilesExist.mockResolvedValue(mockResults); + + 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 mockResults = new Map(); + mockedGithubClientInstance.checkFilesExist.mockResolvedValue(mockResults); + + const result = await provider.calculateMetric(mockEntity); + + expect(result).toBe(false); + }); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts new file mode 100644 index 0000000000..111dee0267 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts @@ -0,0 +1,131 @@ +/* + * 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 { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; +import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; +import { + DEFAULT_BOOLEAN_THRESHOLDS, + Metric, + ThresholdConfig, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { GithubClient } from '../github/GithubClient'; +import { getRepositoryInformationFromEntity } from '../github/utils'; +import { GithubFile, GithubFilesConfig } from '../github/types'; + +export class GithubFilesProvider implements MetricProvider<'boolean'> { + private readonly githubClient: GithubClient; + private readonly thresholds: ThresholdConfig; + private readonly filesConfig: GithubFilesConfig; + + private constructor(config: Config, filesConfig: GithubFilesConfig) { + this.githubClient = new GithubClient(config); + this.filesConfig = filesConfig; + this.thresholds = DEFAULT_BOOLEAN_THRESHOLDS; + } + + getProviderDatasourceId(): string { + return 'github'; + } + + getProviderId(): string { + return 'github.files_check'; + } + + getMetricIds(): string[] { + return this.filesConfig.files.map(f => `github.files_check.${f.id}`); + } + + getMetricType(): 'boolean' { + return 'boolean'; + } + + // Single metric (for backward compatibility) + getMetric(): Metric<'boolean'> { + return this.getMetrics()[0]; + } + + // All metrics this provider exposes + getMetrics(): Metric<'boolean'>[] { + return this.filesConfig.files.map(f => ({ + id: `github.files_check.${f.id}`, + title: `GitHub 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.github.com/project-slug': CATALOG_FILTER_EXISTS, + }; + } + + // Legacy single calculation (shouldn't be called for batch providers) + 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 repository = getRepositoryInformationFromEntity(entity); + const { target } = getEntitySourceLocation(entity); + + const filePathMap = new Map(); + for (const file of this.filesConfig.files) { + filePathMap.set(`github.files_check.${file.id}`, file.path); + } + + const existsMap = await this.githubClient.checkFilesExist( + target, + repository, + filePathMap, + ); + + return existsMap; + } + + static fromConfig(config: Config): GithubFilesProvider | undefined { + const filesConfig = config.getOptionalConfigArray( + 'scorecard.plugins.github.files_check.files', + ); + + if (!filesConfig || filesConfig.length === 0) { + return undefined; + } + + const fileConfigs: GithubFile[] = filesConfig.map(fileConfig => { + const keys = fileConfig.keys(); + if (keys.length !== 1) { + throw new Error( + 'Each file config entry must have exactly one key-value pair', + ); + } + const id = keys[0]; + const path = fileConfig.getString(id); + return { id, path }; + }); + + return new GithubFilesProvider(config, { files: fileConfigs }); + } +} diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts index 267556de1c..6291412ef1 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/module.ts @@ -19,6 +19,7 @@ import { } from '@backstage/backend-plugin-api'; import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { GithubOpenPRsProvider } from './metricProviders/GithubOpenPRsProvider'; +import { GithubFilesProvider } from './metricProviders/GithubFilesProvider'; export const scorecardModuleGithub = createBackendModule({ pluginId: 'scorecard', @@ -31,6 +32,10 @@ export const scorecardModuleGithub = createBackendModule({ }, async init({ config, metrics }) { metrics.addMetricProvider(GithubOpenPRsProvider.fromConfig(config)); + const filesProvider = GithubFilesProvider.fromConfig(config); + if (filesProvider) { + metrics.addMetricProvider(filesProvider); + } }, }); }, diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts index 66043f0158..5de3e53475 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts @@ -41,6 +41,15 @@ export const buildMockMetricProvidersRegistry = ({ const getProvider = provider ? jest.fn().mockReturnValue(provider) : jest.fn(); + const getMetric = provider + ? jest.fn().mockImplementation((metricId: string) => { + if (provider.getMetrics) { + const metric = provider.getMetrics().find(m => m.id === metricId); + if (metric) return metric; + } + return provider.getMetric(); + }) + : jest.fn(); const listMetrics = metricsList ? jest.fn().mockImplementation((metricIds?: string[]) => { if (metricIds && metricIds.length !== 0) { @@ -67,6 +76,7 @@ export const buildMockMetricProvidersRegistry = ({ return { ...mockMetricProvidersRegistry, getProvider, + getMetric, listMetrics, getMetric, } 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..d1b47a532b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts @@ -139,3 +139,106 @@ 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 { + rules: [ + { key: 'success', expression: '==true' }, + { key: 'error', expression: '==false' }, + ], + }; + } + + getCatalogFilter(): Record { + return { + 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, + }; + } + + 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 githubBatchProvider = new MockBatchBooleanProvider( + 'github', + 'github.files_check', + [ + { id: 'readme', path: 'README.md' }, + { id: 'license', path: 'LICENSE' }, + { id: 'codeowners', path: 'CODEOWNERS' }, + ], +); + +export const githubBatchMetrics = [ + { + id: 'github.files_check.readme', + title: 'File: README.md', + description: 'Checks if README.md exists.', + type: 'boolean' as const, + }, + { + id: 'github.files_check.license', + title: 'File: LICENSE', + description: 'Checks if LICENSE exists.', + type: 'boolean' as const, + }, + { + id: 'github.files_check.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..18f5b78214 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, + githubBatchProvider, + githubBatchMetrics, } 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,101 @@ describe('MetricProvidersRegistry', () => { ), ); }); + + describe('batch providers', () => { + it('should register batch provider with multiple metric IDs', () => { + expect(() => registry.register(githubBatchProvider)).not.toThrow(); + + expect(registry.listMetrics()).toEqual(githubBatchMetrics); + }); + + it('should store batch provider under each metric ID', () => { + registry.register(githubBatchProvider); + + // Should be able to get the same provider instance for each metric ID + const provider1 = registry.getProvider('github.files_check.readme'); + const provider2 = registry.getProvider('github.files_check.license'); + const provider3 = registry.getProvider('github.files_check.codeowners'); + + expect(provider1).toBe(githubBatchProvider); + expect(provider2).toBe(githubBatchProvider); + expect(provider3).toBe(githubBatchProvider); + }); + + it('should throw ConflictError when batch provider metric ID conflicts with existing', () => { + const existingProvider = new MockBooleanProvider( + 'github.files_check.readme', + 'github', + ); + registry.register(existingProvider); + + expect(() => registry.register(githubBatchProvider)).toThrow( + new ConflictError( + "Metric provider with ID 'github.files_check.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 [ + 'github.files_check.readme', + 'github.files_check.nonexistent', + ]; + } + getMetrics() { + return [ + { + id: 'github.files_check.readme', + title: 'README', + description: 'README check', + type: 'boolean' as const, + }, + ]; + } + } + + const invalidProvider = new InvalidBatchProvider( + 'github', + 'github.files_check', + [], + ); + + expect(() => registry.register(invalidProvider)).toThrow( + "Invalid metric provider: metric ID 'github.files_check.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', + 'github.files_check', + [], + ); + + 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 +252,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 +273,24 @@ 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(githubBatchProvider); + + const readmeMetric = registry.getMetric('github.files_check.readme'); + const licenseMetric = registry.getMetric('github.files_check.license'); + const codeownersMetric = registry.getMetric( + 'github.files_check.codeowners', + ); + + expect(readmeMetric).toEqual(githubBatchMetrics[0]); + expect(licenseMetric).toEqual(githubBatchMetrics[1]); + expect(codeownersMetric).toEqual(githubBatchMetrics[2]); + }); }); describe('calculateMetric', () => { @@ -197,7 +310,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 +334,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 +363,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 +382,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 +411,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(githubBatchProvider); + 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(githubBatchProvider); + expect(providers).toContain(jiraBooleanProvider); + }); }); describe('listMetrics', () => { @@ -349,6 +474,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(githubBatchProvider); + 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([ + 'github.files_check.readme', + 'github.files_check.license', + 'github.files_check.codeowners', + 'jira.boolean_metric', + ]); + }); + + it('should return specific batch provider metrics when filtered', () => { + const metrics = registry.listMetrics([ + 'github.files_check.readme', + 'github.files_check.codeowners', + ]); + + expect(metrics).toHaveLength(2); + expect(metrics[0].id).toBe('github.files_check.readme'); + expect(metrics[1].id).toBe('github.files_check.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 +562,35 @@ describe('MetricProvidersRegistry', () => { expect(metrics).toEqual([]); }); + + describe('with batch providers', () => { + beforeEach(() => { + registry = new MetricProvidersRegistry(); + registry.register(githubBatchProvider); + registry.register(githubNumberProvider); + registry.register(jiraBooleanProvider); + }); + + it('should return all metrics from batch provider for datasource', () => { + const metrics = registry.listMetricsByDatasource('github'); + + expect(metrics).toHaveLength(4); // 3 from batch + 1 from number provider + expect(metrics.map(m => m.id)).toContain('github.files_check.readme'); + expect(metrics.map(m => m.id)).toContain('github.files_check.license'); + expect(metrics.map(m => m.id)).toContain( + 'github.files_check.codeowners', + ); + expect(metrics.map(m => m.id)).toContain('github.number_metric'); + }); + + it('should not duplicate metrics from batch providers in datasource listing', () => { + const metrics = registry.listMetricsByDatasource('github'); + 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 be6204d224..22111293fc 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts @@ -30,99 +30,135 @@ 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; } - 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()], ); } @@ -133,9 +169,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..fc3054dbf4 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,184 @@ describe('PullMetricsByProviderTask', () => { (task as any).pullProviderMetrics(mockProvider, mockLogger), ).rejects.toThrow('test error'); }); + + describe('batch providers', () => { + let mockBatchProvider: MockBatchBooleanProvider; + + beforeEach(() => { + mockBatchProvider = new MockBatchBooleanProvider( + 'github', + 'github.files_check', + [ + { 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: 'github.files_check.readme', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'github.files_check.license', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'github.files_check.readme', + value: true, + status: 'success', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'github.files_check.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: 'github.files_check.readme', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test1', + metric_id: 'github.files_check.license', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'github.files_check.readme', + error_message: 'GitHub API error', + }), + expect.objectContaining({ + catalog_entity_ref: 'component:default/test2', + metric_id: 'github.files_check.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: 'github.files_check.readme', + value: true, + error_message: 'Threshold evaluation failed', + }), + ]), + ); + }); + + 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.github.files_check.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..593e48e997 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,62 @@ export class PullMetricsByProviderTask implements SchedulerTask { const batchResults = await Promise.allSettled( entitiesResponse.items.map(async entity => { + // Handle batch providers + if (isBatchProvider && provider.calculateMetrics) { + try { + const resultsMap = await provider.calculateMetrics(entity); + + // Create a result for each metric ID + return metricIds.map(metricId => { + const value = resultsMap.get(metricId)!; + + try { + const thresholds = mergeEntityAndProviderThresholds( + entity, + provider, + ); + + const status = + this.thresholdEvaluator.getFirstMatchingThreshold( + value, + metricType, + thresholds, + ); + + return { + catalog_entity_ref: stringifyEntityRef(entity), + metric_id: metricId, + value, + timestamp: new Date(), + status, + } as DbMetricValueCreate; + } catch (error) { + return { + catalog_entity_ref: stringifyEntityRef(entity), + metric_id: metricId, + value, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + } as DbMetricValueCreate; + } + }); + } catch (error) { + // If batch calculation fails, create error records for all metrics + return metricIds.map( + metricId => + ({ + catalog_entity_ref: stringifyEntityRef(entity), + metric_id: metricId, + value: undefined, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + } as DbMetricValueCreate), + ); + } + } + let value: MetricValue | undefined; try { @@ -197,12 +255,33 @@ 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[]), ); + // Log summary of batch results for debugging, will remove before final PR + if (batchResults.length > 0) { + const summary = batchResults.map(r => ({ + entity: r.catalog_entity_ref, + metric: r.metric_id, + value: r.value, + status: r.status, + ...(r.error_message && { error: r.error_message }), + })); + logger.info( + `Storing ${batchResults.length} metric values: ${JSON.stringify( + summary, + )}`, + ); + } + 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 51554961b0..f545e06a16 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -19,7 +19,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, + githubBatchProvider, + githubBatchMetrics, +} from '../../__fixtures__/mockProviders'; import { buildMockDatabaseMetricValues, mockDatabaseMetricValues, @@ -177,6 +181,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' }, @@ -291,14 +300,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 () => { @@ -441,6 +450,80 @@ describe('CatalogMetricService', () => { }); }); + describe('getLatestEntityMetrics with batch providers', () => { + it('should return correct per-metric metadata for batch provider metrics', async () => { + const batchMetricsList = githubBatchMetrics.map(m => ({ + id: m.id, + })) as Metric[]; + + mockedRegistry = buildMockMetricProvidersRegistry({ + provider: githubBatchProvider, + metricsList: batchMetricsList, + }); + + mockedRegistry.getProvider.mockReturnValue(githubBatchProvider); + mockedRegistry.getMetric.mockImplementation((metricId: string) => { + const found = githubBatchMetrics.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: 'github.files_check.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: 'github.files_check.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, + }); + + const results = await service.getLatestEntityMetrics( + 'component:default/test-component', + ); + + expect(results).toHaveLength(2); + + expect(results[0].id).toBe('github.files_check.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('github.files_check.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 097e2460f9..3e5b483752 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -70,17 +70,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 >, @@ -92,7 +92,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, @@ -109,7 +109,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 41aa1c9368..b98eab1093 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -631,7 +631,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 () => { diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 3e9e1a0078..410319f932 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -34,6 +34,9 @@ export type AggregatedMetricValue = { name: string; }; +// @public +export const DEFAULT_BOOLEAN_THRESHOLDS: ThresholdConfig; + // @public export const DEFAULT_NUMBER_THRESHOLDS: ThresholdConfig; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts index 314a727e76..2c3e11d018 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts @@ -54,6 +54,17 @@ export const DEFAULT_NUMBER_THRESHOLDS: ThresholdConfig = { ], }; +/** + * Default threshold configuration for boolean metrics + * @public + */ +export const DEFAULT_BOOLEAN_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '==true' }, + { key: 'error', expression: '==false' }, + ], +}; + /** * Predefined scorecard threshold rule color constants. * Use in threshold rule color configurations instead of hex/RGB values. 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>>; } From 6a608284f7c4889b425b1cf551285a5d72fbbd8b Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Mon, 23 Mar 2026 11:04:18 +0100 Subject: [PATCH 02/20] feat: display correct file check scorecards Signed-off-by: Diana Janickova feat: implement aggregated card and update test Signed-off-by: Diana Janickova feat: include aggregated card in App.tsx Signed-off-by: Diana Janickova fix: pass logger as param Signed-off-by: Diana Janickova ref: remove default boolean tresholds, edit translation Signed-off-by: Diana Janickova chore: generate api reports Signed-off-by: Diana Janickova test: e2e test scenario for file checks Signed-off-by: Diana Janickova fix: selectors in test Signed-off-by: Diana Janickova feat: include file check section in app-config.yaml Signed-off-by: Diana Janickova fix: aggregated permission card title and desc Signed-off-by: Diana Janickova ref: move tresholds from scorecard-common Signed-off-by: Diana Janickova fix: off center icon and treshold imports Signed-off-by: Diana Janickova feat: add new translations Signed-off-by: Diana Janickova ref: implement resolveMetricTranslation Signed-off-by: Diana Janickova fix: resolve translations correctly Signed-off-by: Diana Janickova ref: use useMemo in EmptyStatePanel Signed-off-by: Diana Janickova fix: use translations in test Signed-off-by: Diana Janickova --- workspaces/scorecard/app-config.yaml | 8 + .../packages/app/e2e-tests/scorecard.test.ts | 62 ++++++ .../e2e-tests/utils/scorecardResponseUtils.ts | 72 ++++++- workspaces/scorecard/packages/app/src/App.tsx | 74 ++++++- .../src/metricProviders/GithubConfig.ts | 35 ++++ .../GithubFilesProvider.test.ts | 6 +- .../metricProviders/GithubFilesProvider.ts | 4 +- .../mockMetricProvidersRegistry.ts | 15 +- .../src/service/CatalogMetricService.test.ts | 1 + .../src/service/router.test.ts | 39 ++++ .../scorecard-backend/src/service/router.ts | 2 +- .../plugins/scorecard-common/report.api.md | 3 - .../scorecard-common/src/types/threshold.ts | 11 -- .../plugins/scorecard/report-alpha.api.md | 4 + .../scorecard/plugins/scorecard/report.api.md | 4 + .../src/components/Scorecard/CustomLegend.tsx | 6 +- .../Scorecard/EntityScorecardContent.tsx | 31 ++- .../src/components/Scorecard/Scorecard.tsx | 11 +- .../__tests__/EntityScorecardContent.test.tsx | 4 + .../EmptyStatePanel.tsx | 21 +- .../ScorecardHomepageCard.tsx | 31 +-- .../plugins/scorecard/src/translations/de.ts | 5 + .../plugins/scorecard/src/translations/es.ts | 6 + .../plugins/scorecard/src/translations/fr.ts | 6 + .../plugins/scorecard/src/translations/it.ts | 5 + .../plugins/scorecard/src/translations/ja.ts | 5 + .../plugins/scorecard/src/translations/ref.ts | 6 + .../src/utils/__tests__/statusUtils.test.tsx | 55 +++++- .../utils/__tests__/translationUtils.test.ts | 180 ++++++++++++++++++ .../plugins/scorecard/src/utils/index.ts | 1 + .../scorecard/src/utils/statusUtils.ts | 1 + .../scorecard/src/utils/translationUtils.ts | 56 ++++++ 32 files changed, 696 insertions(+), 74 deletions(-) create mode 100644 workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts create mode 100644 workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index 73ae4f7ea8..29473c71e9 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -133,3 +133,11 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 5 } + files_check: + files: + - readme: 'README.md' + - i18guide: 'docs/backstage-i18n-guide.md' + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 5 } diff --git a/workspaces/scorecard/packages/app/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app/e2e-tests/scorecard.test.ts index f1e6d181d0..86378738e0 100644 --- a/workspaces/scorecard/packages/app/e2e-tests/scorecard.test.ts +++ b/workspaces/scorecard/packages/app/e2e-tests/scorecard.test.ts @@ -27,6 +27,7 @@ import { emptyScorecardResponse, unavailableMetricResponse, invalidThresholdResponse, + fileCheckScorecardResponse, githubAggregatedResponse, jiraAggregatedResponse, emptyGithubAggregatedResponse, @@ -188,6 +189,67 @@ test.describe('Scorecard Plugin Tests', () => { await runAccessibilityTests(page, testInfo); }); + + test('Verify file check metrics display correctly', async ({ + browser, + }, testInfo) => { + await mockScorecardResponse(page, 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['github.files_check'].title, + 'readme', + ); + const readmeDescription = evaluateMessage( + translations.metric['github.files_check'].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['github.files_check'].title, + 'codeowners', + ); + const codeownersDescription = evaluateMessage( + translations.metric['github.files_check'].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('Aggregated Scorecards', () => { diff --git a/workspaces/scorecard/packages/app/e2e-tests/utils/scorecardResponseUtils.ts b/workspaces/scorecard/packages/app/e2e-tests/utils/scorecardResponseUtils.ts index 45138859c3..3c5b06fe61 100644 --- a/workspaces/scorecard/packages/app/e2e-tests/utils/scorecardResponseUtils.ts +++ b/workspaces/scorecard/packages/app/e2e-tests/utils/scorecardResponseUtils.ts @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -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'; export const customScorecardResponse = [ { @@ -260,3 +263,70 @@ export const emptyGithubAggregatedResponse = { thresholds: DEFAULT_NUMBER_THRESHOLDS, }, }; + +export const fileCheckScorecardResponse = [ + { + id: 'github.files_check.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: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }, + status: 'success', + evaluation: 'exist', + }, + }, + }, + { + id: 'github.files_check.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: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }, + status: 'success', + evaluation: 'missing', + }, + }, + }, +]; diff --git a/workspaces/scorecard/packages/app/src/App.tsx b/workspaces/scorecard/packages/app/src/App.tsx index 9ce84471e1..33bac835d3 100644 --- a/workspaces/scorecard/packages/app/src/App.tsx +++ b/workspaces/scorecard/packages/app/src/App.tsx @@ -146,6 +146,66 @@ const mountPoints: HomePageCardMountPoint[] = [ }, }, }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-github.files_check.readme', + title: 'Scorecard: README 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: { + metricId: 'github.files_check.readme', + }, + }, + }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-github.files_check.i18guide', + title: 'Scorecard: i18n guide 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: { + metricId: 'github.files_check.i18guide', + }, + }, + }, { Component: ScorecardHomepageCard as ComponentType, config: { @@ -180,14 +240,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', + 'github.files_check.readme', + 'github.files_check.i18guide', + ], }, }, }, uiSchema: { metricId: { 'ui:widget': 'RadioWidget', - 'ui:enumNames': ['Jira Open Issues', 'GitHub Open PRs'], + 'ui:enumNames': [ + 'Jira Open Issues', + 'GitHub Open PRs', + 'README file exists', + 'i18n guide file exists', + ], }, }, }, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts new file mode 100644 index 0000000000..e1feeb4770 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts @@ -0,0 +1,35 @@ +/* + * 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 ThresholdConfig, + ScorecardThresholdRuleColors, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +export const DEFAULT_FILE_CHECK_THRESHOLDS: ThresholdConfig = { + rules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], +}; diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts index da380fa6fd..3313150042 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -18,7 +18,7 @@ import { ConfigReader } from '@backstage/config'; import type { Entity } from '@backstage/catalog-model'; import { GithubFilesProvider } from './GithubFilesProvider'; import { GithubClient } from '../github/GithubClient'; -import { DEFAULT_BOOLEAN_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { DEFAULT_FILE_CHECK_THRESHOLDS } from './GithubConfig'; jest.mock('@backstage/catalog-model', () => ({ ...jest.requireActual('@backstage/catalog-model'), @@ -138,9 +138,9 @@ describe('GithubFilesProvider', () => { ]); }); - it('should return default boolean thresholds', () => { + it('should return default file check thresholds', () => { expect(provider.getMetricThresholds()).toEqual( - DEFAULT_BOOLEAN_THRESHOLDS, + DEFAULT_FILE_CHECK_THRESHOLDS, ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts index 111dee0267..16ed30b076 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts @@ -18,10 +18,10 @@ import type { Config } from '@backstage/config'; import { getEntitySourceLocation, type Entity } from '@backstage/catalog-model'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { - DEFAULT_BOOLEAN_THRESHOLDS, Metric, ThresholdConfig, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { DEFAULT_FILE_CHECK_THRESHOLDS } from './GithubConfig'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { GithubClient } from '../github/GithubClient'; import { getRepositoryInformationFromEntity } from '../github/utils'; @@ -35,7 +35,7 @@ export class GithubFilesProvider implements MetricProvider<'boolean'> { private constructor(config: Config, filesConfig: GithubFilesConfig) { this.githubClient = new GithubClient(config); this.filesConfig = filesConfig; - this.thresholds = DEFAULT_BOOLEAN_THRESHOLDS; + this.thresholds = DEFAULT_FILE_CHECK_THRESHOLDS; } getProviderDatasourceId(): string { diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts index 5de3e53475..e067a394b3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts @@ -41,15 +41,6 @@ export const buildMockMetricProvidersRegistry = ({ const getProvider = provider ? jest.fn().mockReturnValue(provider) : jest.fn(); - const getMetric = provider - ? jest.fn().mockImplementation((metricId: string) => { - if (provider.getMetrics) { - const metric = provider.getMetrics().find(m => m.id === metricId); - if (metric) return metric; - } - return provider.getMetric(); - }) - : jest.fn(); const listMetrics = metricsList ? jest.fn().mockImplementation((metricIds?: string[]) => { if (metricIds && metricIds.length !== 0) { @@ -61,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; @@ -78,6 +74,5 @@ export const buildMockMetricProvidersRegistry = ({ getProvider, getMetric, listMetrics, - getMetric, } as unknown as jest.Mocked; }; 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 f545e06a16..a9542d056b 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -502,6 +502,7 @@ describe('CatalogMetricService', () => { auth: mockedAuth, registry: mockedRegistry, database: mockedDatabase, + logger: mockedLogger, }); const results = await service.getLatestEntityMetrics( 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 b98eab1093..b1d741ef4d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -26,6 +26,7 @@ import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; import { MockNumberProvider, MockBooleanProvider, + MockBatchBooleanProvider, githubNumberMetricMetadata, } from '../../__fixtures__/mockProviders'; import { @@ -758,6 +759,44 @@ describe('createRouter', () => { mockAggregatedMetric, ); }); + + it('should use registry.getMetric to resolve the correct metric for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + 'github', + 'github.files_check', + [ + { 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/github.files_check.license/catalog/aggregations', + ); + + expect(response.status).toBe(200); + expect(toAggregatedMetricResultSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'github.files_check.license', + title: 'File: LICENSE', + }), + batchProvider.getMetricThresholds(), + mockAggregatedMetric, + ); + }); }); describe('GET /metrics/:metricId/catalog/aggregations/entities', () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts index df798675b1..e1455fca55 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -161,7 +161,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) { diff --git a/workspaces/scorecard/plugins/scorecard-common/report.api.md b/workspaces/scorecard/plugins/scorecard-common/report.api.md index 410319f932..3e9e1a0078 100644 --- a/workspaces/scorecard/plugins/scorecard-common/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-common/report.api.md @@ -34,9 +34,6 @@ export type AggregatedMetricValue = { name: string; }; -// @public -export const DEFAULT_BOOLEAN_THRESHOLDS: ThresholdConfig; - // @public export const DEFAULT_NUMBER_THRESHOLDS: ThresholdConfig; diff --git a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts index 2c3e11d018..314a727e76 100644 --- a/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts +++ b/workspaces/scorecard/plugins/scorecard-common/src/types/threshold.ts @@ -54,17 +54,6 @@ export const DEFAULT_NUMBER_THRESHOLDS: ThresholdConfig = { ], }; -/** - * Default threshold configuration for boolean metrics - * @public - */ -export const DEFAULT_BOOLEAN_THRESHOLDS: ThresholdConfig = { - rules: [ - { key: 'success', expression: '==true' }, - { key: 'error', expression: '==false' }, - ], -}; - /** * Predefined scorecard threshold rule color constants. * Use in threshold rule color configurations instead of hex/RGB values. diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 299f70ef3d..ba77437c1f 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -34,9 +34,13 @@ 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.github.files_check.title': string; + readonly 'metric.github.files_check.description': string; readonly 'thresholds.success': string; readonly 'thresholds.error': string; readonly 'thresholds.warning': 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 28d422b8fd..6503429936 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -49,9 +49,13 @@ 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.github.files_check.title': string; + readonly 'metric.github.files_check.description': string; readonly 'thresholds.success': string; readonly 'thresholds.error': string; readonly 'thresholds.warning': 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/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 282b122259..351a4142d3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -23,7 +23,7 @@ import CircularProgress from '@mui/material/CircularProgress'; 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'; @@ -78,25 +78,24 @@ 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 ( null)} value={metric.result?.value} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index 373b1ef40d..c52e691ba3 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -101,9 +101,16 @@ const ScorecardCenterLabel = ({ ); }, [isErrorState, errorLabel]); + const hasDisplayableValue = !isErrorState && typeof value === 'number'; + return ( - + - {!isErrorState && ( + {hasDisplayableValue && ( ({ jest.mock('../../../utils', () => ({ getStatusConfig: jest.fn(), + resolveMetricTranslation: jest.fn( + (_t: any, _metricId: string, _field: string, fallback?: string) => + fallback ?? `metric.${_metricId}.${_field}`, + ), })); // Get the mocked functions diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx index 645972c600..7dd3999cb1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/EmptyStatePanel.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; @@ -25,6 +25,7 @@ import { getYOffsetForCenterLabel, getHeightForCenterLabel, resolveStatusColor, + resolveMetricTranslation, } from '../../utils'; import CustomLegend from '../Scorecard/CustomLegend'; import { ErrorTooltip } from '../Common/ErrorTooltip'; @@ -103,10 +104,14 @@ export const EmptyStatePanel = ({ label, metricId, tooltipContent, + fallbackTitle, + fallbackDescription, }: { label: string; metricId: string; tooltipContent: string; + fallbackTitle?: string; + fallbackDescription?: string; }) => { const theme = useTheme(); const { t } = useTranslation(); @@ -114,11 +119,15 @@ export const EmptyStatePanel = ({ const [isLabelHovered, setIsLabelHovered] = useState(false); const [isInsidePieCircle, setIsInsidePieCircle] = useState(false); - const titleKey = `metric.${metricId}.title`; - const descriptionKey = `metric.${metricId}.description`; - - const cardTitle = t(titleKey as any, {}); - const cardDescription = t(descriptionKey as any, {}); + const cardTitle = useMemo( + () => resolveMetricTranslation(t, metricId, 'title', fallbackTitle), + [t, metricId, fallbackTitle], + ); + const cardDescription = useMemo( + () => + resolveMetricTranslation(t, metricId, 'description', fallbackDescription), + [t, metricId, fallbackDescription], + ); const statusConfig = getStatusConfig({ evaluation: 'error', diff --git a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx index d29534de75..70984da690 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/ScorecardHomepageSection/ScorecardHomepageCard.tsx @@ -20,6 +20,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import { ScorecardHomepageCardComponent } from './ScorecardHomepageCardComponent'; import { useAggregatedScorecard } from '../../hooks/useAggregatedScorecard'; import { useTranslation } from '../../hooks/useTranslation'; +import { resolveMetricTranslation } from '../../utils'; import { ErrorStatePanel } from './ErrorStatePanel'; import { EmptyStatePanel } from './EmptyStatePanel'; @@ -57,28 +58,30 @@ export const ScorecardHomepageCard = ({ metricId }: { metricId: string }) => { metricId={metricId} label={t('errors.noDataFound')} tooltipContent={t('errors.noDataFoundMessage')} + fallbackTitle={aggregatedScorecard.metadata.title} + fallbackDescription={aggregatedScorecard.metadata.description} /> ); } - const titleKey = `metric.${aggregatedScorecard.id}.title`; - const descriptionKey = `metric.${aggregatedScorecard.id}.description`; - - const title = t(titleKey as any, {}); - const description = t(descriptionKey as any, {}); - - const finalTitle = - title === titleKey ? aggregatedScorecard.metadata.title : title; - const finalDescription = - description === descriptionKey - ? aggregatedScorecard.metadata.description - : description; + const title = resolveMetricTranslation( + t, + aggregatedScorecard.id, + 'title', + aggregatedScorecard.metadata.title, + ); + const description = resolveMetricTranslation( + t, + aggregatedScorecard.id, + 'description', + aggregatedScorecard.metadata.description, + ); return ( ); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index d6f8b8ca25..9e53122217 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -40,6 +40,11 @@ const scorecardTranslationDe = createTranslationMessages({ 'errors.missingPermission': 'Fehlende Berechtigung', 'errors.missingPermissionMessage': 'Um die Metriken der Scorecard einzusehen, muss Ihnen der Administrator die erforderliche Berechtigung erteilen.', + 'metric.github.files_check.title': 'GitHub-Dateiprüfung: {{name}}', + 'metric.github.files_check.description': + 'Prüft, ob die Datei {{name}} im Repository vorhanden ist.', + 'thresholds.exist': 'Vorhanden', + 'thresholds.missing': 'Fehlend', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index 7ffd2cf702..831aba307f 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -42,6 +42,12 @@ const scorecardTranslationEs = createTranslationMessages({ 'errors.missingPermission': 'Permiso faltante', 'errors.missingPermissionMessage': 'Para ver las métricas de la tarjeta de puntuación, su administrador debe otorgarle el permiso requerido.', + 'metric.github.files_check.title': + 'Verificación de archivo en GitHub: {{name}}', + 'metric.github.files_check.description': + 'Verifica si el archivo {{name}} existe en el repositorio.', + 'thresholds.exist': 'Existe', + 'thresholds.missing': 'Faltante', }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index be8598750d..80c3ef0b64 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -60,9 +60,15 @@ 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.github.files_check.title': + 'Vérification de fichier GitHub : {{name}}', + 'metric.github.files_check.description': + 'Vérifie si le fichier {{name}} existe dans le dépôt.', '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 9e779394b6..3062657081 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -59,9 +59,14 @@ 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.github.files_check.title': 'Verifica file GitHub: {{name}}', + 'metric.github.files_check.description': + 'Verifica se il file {{name}} esiste nel repository.', '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 2c2de8c7d2..e1c10f1966 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -59,9 +59,14 @@ const scorecardTranslationJa = createTranslationMessages({ 'Jira のオープン状態の進行を妨げているチケット', 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', + 'metric.github.files_check.title': 'GitHub ファイル確認: {{name}}', + 'metric.github.files_check.description': + 'リポジトリーに {{name}} ファイルが存在するかを確認します。', '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 24fc118690..5bdae560de 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -71,6 +71,10 @@ export const scorecardMessages = { description: 'Highlights the number of critical, blocking issues that are currently open in Jira.', }, + 'github.files_check': { + title: 'GitHub file check: {{name}}', + description: 'Checks whether the {{name}} file exists in the repository.', + }, }, // Threshold translations @@ -78,6 +82,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 92f26cdeb6..10ca217fb4 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx @@ -18,7 +18,10 @@ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import DangerousOutlinedIcon from '@mui/icons-material/DangerousOutlined'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; -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, SCORECARD_ERROR_STATE_COLOR } from '..'; describe('statusUtils', () => { @@ -159,6 +162,56 @@ describe('statusUtils', () => { icon: CheckCircleOutlineIcon, }); }); + + it('should return DangerousOutlinedIcon for missing evaluation', () => { + const result = getStatusConfig({ + evaluation: 'missing', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }); + + expect(result).toEqual({ + color: 'error.main', + icon: DangerousOutlinedIcon, + }); + }); + + it('should return CheckCircleOutlineIcon for exist evaluation', () => { + const result = getStatusConfig({ + evaluation: 'exist', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + }, + ], + }); + + expect(result).toEqual({ + color: 'success.main', + icon: CheckCircleOutlineIcon, + }); + }); }); 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..35fd6881a7 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts @@ -0,0 +1,180 @@ +/* + * 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 3-segment metric IDs', () => { + const t = createMockT({ + 'metric.github.files_check.title': 'GitHub file check: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'github.files_check.readme', 'title'), + ).toBe('GitHub file check: readme'); + }); + + it('returns parent translation for description field', () => { + const t = createMockT({ + 'metric.github.files_check.description': + 'Checks whether the {{name}} file exists in the repository.', + }); + + expect( + resolveMetricTranslation( + t as any, + 'github.files_check.readme', + 'description', + ), + ).toBe('Checks whether the readme file exists in the repository.'); + }); + + it('prefers exact match over parent match', () => { + const t = createMockT({ + 'metric.github.files_check.readme.title': 'README file check', + 'metric.github.files_check.title': 'GitHub file check: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'github.files_check.readme', 'title'), + ).toBe('README file check'); + }); + + it('joins multiple suffix segments as the name parameter', () => { + const t = createMockT({ + 'metric.some.provider.title': 'Provider: {{name}}', + }); + + expect( + resolveMetricTranslation(t as any, 'some.provider.deep.nested', 'title'), + ).toBe('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('does not attempt parent lookup for 2-segment metric IDs', () => { + const t = createMockT({ + 'metric.unknown.title': 'Should not match', + }); + + expect( + resolveMetricTranslation(t as any, 'unknown.something', 'title'), + ).toBe('metric.unknown.something.title'); + }); + + 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.github.files_check.title': 'GitHub file check: {{name}}', + }); + + expect( + resolveMetricTranslation( + t as any, + 'github.files_check.readme', + 'title', + 'Fallback Title', + ), + ).toBe('GitHub file check: readme'); + }); +}); diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts index e31b9a24e0..a0152b8657 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -21,3 +21,4 @@ export { export { getThresholdRuleColor, resolveStatusColor } from './colorUtils'; export { SCORECARD_ERROR_STATE_COLOR } from './constants'; export { getStatusConfig } from './statusUtils'; +export { resolveMetricTranslation } from './translationUtils'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts index d4b7990584..96396cdebb 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/statusUtils.ts @@ -57,6 +57,7 @@ export const getStatusConfig = ({ switch (evaluation) { case 'error': + case 'missing': return { color, icon: DangerousOutlinedIcon }; case 'warning': return { color, icon: WarningAmberIcon }; 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..8fa7fb2d81 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts @@ -0,0 +1,56 @@ +/* + * 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.metricId.field (e.g. metric.github.open_prs.title) + * 2. Parent key with instance suffix as name param: + * for a metric ID with 3+ dot-separated segments, strips the suffix after + * the first two segments and tries metric.base.field with name = suffix. + * E.g. github.files_check.readme tries metric.github.files_check.title + * with name = readme. + * 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 segments = metricId.split('.'); + if (segments.length > 2) { + const base = segments.slice(0, 2).join('.'); + const name = segments.slice(2).join('.'); + const parentKey = `metric.${base}.${field}`; + const parentTranslated = t(parentKey as any, { name }); + if (parentTranslated !== parentKey) return parentTranslated; + } + + return fallback ?? translated; +} From 155643b0dcd246007402f7eba348c3225fe88d13 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 09:28:50 +0200 Subject: [PATCH 03/20] ref: use resolveMetricTranslation helper Signed-off-by: Diana Janickova --- .../__tests__/useMetricDisplayLabels.test.tsx | 59 +++++++++++++++++++ .../src/hooks/useMetricDisplayLabels.tsx | 23 +++----- 2 files changed, 67 insertions(+), 15 deletions(-) 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..2db59bacc2 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: 'github.files_check.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.github.files_check.title') + return `File Check: ${params?.name}`; + if (key === 'metric.github.files_check.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.github.files_check.readme.title') + return 'Exact README Title'; + if (key === 'metric.github.files_check.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, + ), }; }; From 4c87647b2f8fea59e31315c9e7a7549005f72896 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 10:13:39 +0200 Subject: [PATCH 04/20] chore: re-add changes not solved in conflicts Signed-off-by: Diana Janickova --- .../app-legacy/e2e-tests/scorecard.test.ts | 66 +++++++++++++++++ .../e2e-tests/utils/scorecardResponseUtils.ts | 71 ++++++++++++++++++ .../scorecard/packages/app-legacy/src/App.tsx | 74 ++++++++++++++++++- .../src/metricProviders/GithubConfig.ts | 2 + .../plugins/scorecard/report-alpha.api.md | 8 +- .../scorecard/plugins/scorecard/report.api.md | 8 +- .../src/utils/__tests__/statusUtils.test.tsx | 65 +++++++++++++++- 7 files changed, 276 insertions(+), 18 deletions(-) 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 e81c0a737c..8936305fdc 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['github.files_check'].title, + 'readme', + ); + const readmeDescription = evaluateMessage( + translations.metric['github.files_check'].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['github.files_check'].title, + 'codeowners', + ); + const codeownersDescription = evaluateMessage( + translations.metric['github.files_check'].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..b6c0cbc71c 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: 'github.files_check.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: 'github.files_check.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..50f83d7a93 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-github.files_check.readme', + title: 'Scorecard: README 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: 'github.files_check.readme', + }, + }, + }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-github.files_check.i18guide', + title: 'Scorecard: i18n guide 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: 'github.files_check.i18guide', + }, + }, + }, { 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', + 'github.files_check.readme', + 'github.files_check.i18guide', + ], }, }, }, uiSchema: { metricId: { 'ui:widget': 'RadioWidget', - 'ui:enumNames': ['Jira Open Issues', 'GitHub Open PRs'], + 'ui:enumNames': [ + 'Jira Open Issues', + 'GitHub Open PRs', + 'README file exists', + 'i18n guide file exists', + ], }, }, }, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts index e1feeb4770..8a6d975ded 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts @@ -25,11 +25,13 @@ export const DEFAULT_FILE_CHECK_THRESHOLDS: ThresholdConfig = { key: 'exist', expression: '==true', color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', }, { key: 'missing', expression: '==false', color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', }, ], }; diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index 1c62a88157..2ff7b9ff4a 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -121,22 +121,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; -<<<<<<< HEAD readonly 'metric.github.files_check.title': string; readonly 'metric.github.files_check.description': string; -======= readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; ->>>>>>> main readonly 'thresholds.success': string; readonly 'thresholds.warning': string; -<<<<<<< HEAD + readonly 'thresholds.error': string; readonly 'thresholds.exist': string; readonly 'thresholds.missing': string; -======= - readonly 'thresholds.error': string; ->>>>>>> main 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 b36fac2dda..524529b7ab 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -74,22 +74,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; -<<<<<<< HEAD readonly 'metric.github.files_check.title': string; readonly 'metric.github.files_check.description': string; -======= readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; ->>>>>>> main readonly 'thresholds.success': string; readonly 'thresholds.warning': string; -<<<<<<< HEAD + readonly 'thresholds.error': string; readonly 'thresholds.exist': string; readonly 'thresholds.missing': string; -======= - readonly 'thresholds.error': string; ->>>>>>> main readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; 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 b08085f1ee..888c8b57b6 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx @@ -17,8 +17,15 @@ import type { Theme } from '@mui/material/styles'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; -import { getStatusConfig, resolveStatusColor, SCORECARD_ERROR_STATE_COLOR } from '..'; +import { + DEFAULT_NUMBER_THRESHOLDS, + ScorecardThresholdRuleColors, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; +import { + getStatusConfig, + resolveStatusColor, + SCORECARD_ERROR_STATE_COLOR, +} from '..'; describe('statusUtils', () => { describe('getStatusConfig', () => { @@ -168,6 +175,60 @@ describe('statusUtils', () => { icon: CheckCircleOutlineIcon, }); }); + + 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', () => { From 154747ee2a1b38f6bed3bf593a8140d2bcc8f92b Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 11:17:24 +0200 Subject: [PATCH 05/20] chore: fix yarn tsc Signed-off-by: Diana Janickova --- .../scorecard-backend/src/service/CatalogMetricService.test.ts | 1 + 1 file changed, 1 insertion(+) 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 a3aef3168c..eb3947cb6c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -506,6 +506,7 @@ describe('CatalogMetricService', () => { registry: mockedRegistry, database: mockedDatabase, logger: mockedLogger, + config: mockServices.rootConfig({ data: {} }), }); const results = await service.getLatestEntityMetrics( From 3956b85557a124343df1a3a80fad84991e461c6f Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 11:19:37 +0200 Subject: [PATCH 06/20] fix: do not show icon for boolean metric type Signed-off-by: Diana Janickova --- .../src/components/Scorecard/EntityScorecardContent.tsx | 1 + .../scorecard/src/components/Scorecard/Scorecard.tsx | 8 +++++++- .../src/components/Scorecard/__tests__/Scorecard.test.tsx | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index 0daa5a53a9..5cbe486b15 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -90,6 +90,7 @@ export const EntityScorecardContent = () => { statusColor={statusConfig.color} statusIcon={statusConfig.icon ?? ''} value={metric.result?.value} + metricType={metric.metadata.type} thresholds={metric.result?.thresholdResult} isMetricDataError={isMetricDataError} metricDataError={metric?.error} diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx index 79d304c0e2..adb3d1b982 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/Scorecard.tsx @@ -17,6 +17,7 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { + MetricType, MetricValue, ThresholdResult, } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; @@ -50,6 +51,7 @@ interface ScorecardProps { statusColor: string; statusIcon: string; value: MetricValue | null; + metricType: MetricType; thresholds?: ThresholdResult; isMetricDataError?: boolean; metricDataError?: string; @@ -62,6 +64,7 @@ const ScorecardCenterLabel = ({ cy, statusIcon, value, + metricType, isErrorState, errorLabel, color, @@ -72,6 +75,7 @@ const ScorecardCenterLabel = ({ cy: number; statusIcon: string; value: MetricValue | null; + metricType: MetricType; isErrorState: boolean; errorLabel: string; color: string | undefined; @@ -102,7 +106,7 @@ const ScorecardCenterLabel = ({ ); }, [isErrorState, errorLabel]); - const hasDisplayableValue = !isErrorState && typeof value === 'number'; + const hasDisplayableValue = !isErrorState && metricType === 'number'; return ( @@ -169,6 +173,7 @@ const Scorecard = ({ statusColor, statusIcon, value, + metricType, thresholds, isMetricDataError = false, metricDataError, @@ -272,6 +277,7 @@ const Scorecard = ({ cy={Number(cy)} statusIcon={statusIcon} value={value} + metricType={metricType} isErrorState={isErrorState} errorLabel={errorLabel} color={resolvedStatusColor} 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: { From b6b3404bacdff30ff2f99edc18eb1d8db9c185c0 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 11:47:51 +0200 Subject: [PATCH 07/20] fix: pass extra props to db in pullProviderMetrics Signed-off-by: Diana Janickova --- .../tasks/PullMetricsByProviderTask.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 593e48e997..9cf3183b66 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts @@ -145,6 +145,11 @@ export class PullMetricsByProviderTask implements SchedulerTask { 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); + try { const resultsMap = await provider.calculateMetrics(entity); @@ -166,20 +171,26 @@ export class PullMetricsByProviderTask implements SchedulerTask { ); return { - catalog_entity_ref: stringifyEntityRef(entity), + 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: stringifyEntityRef(entity), + 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; } }); @@ -188,12 +199,15 @@ export class PullMetricsByProviderTask implements SchedulerTask { return metricIds.map( metricId => ({ - catalog_entity_ref: stringifyEntityRef(entity), + 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), ); } From 269ca6d02c38f3fd7455f3dbf97e9a4b25577f3e Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 14:12:10 +0200 Subject: [PATCH 08/20] fix: aggregations endpoint for batch providers Signed-off-by: Diana Janickova --- .../src/service/router.test.ts | 66 +++++++++++++++++++ .../scorecard-backend/src/service/router.ts | 13 ++-- 2 files changed, 73 insertions(+), 6 deletions(-) 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 aeeb350d8f..6725f5df00 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -985,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( + 'github', + 'github.files_check', + [ + { 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/github.files_check.license', + ); + + expect(response.status).toBe(200); + expect(getAggregatedSpy).toHaveBeenCalledWith( + ['component:default/my-service', 'component:default/my-other-service'], + 'github.files_check.license', + aggregationTypes.statusGrouped, + ); + }); + it('should use KPI config metricId and type when aggregationId is a KPI key', async () => { const kpiService = new CatalogMetricService({ catalog: mockCatalog, @@ -1102,6 +1137,37 @@ describe('createRouter', () => { }); }); + it('should resolve the correct metric metadata for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + 'github', + 'github.files_check', + [ + { 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/github.files_check.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 ab7ee91eb0..ce8065f3fe 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -151,9 +151,9 @@ export async function createRouter({ scorecardMetricReadPermission, ); - const provider = metricProvidersRegistry.getProvider(metricId); - const metric = metricProvidersRegistry.getMetric(metricId); - const authorizedMetrics = filterAuthorizedMetrics([metric], conditions); + const provider = metricProvidersRegistry.getProvider(metricId); + const metric = metricProvidersRegistry.getMetric(metricId); + const authorizedMetrics = filterAuthorizedMetrics([metric], conditions); if (authorizedMetrics.length === 0) { throw new NotAllowedError( @@ -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), From 31c239342fded28d98b822608d64788773ac5a9c Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 14:13:29 +0200 Subject: [PATCH 09/20] chore: fix prettier Signed-off-by: Diana Janickova --- workspaces/scorecard/plugins/scorecard/src/translations/de.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index 08059a7b0d..c9391b3300 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -81,7 +81,7 @@ const scorecardTranslationDe = createTranslationMessages({ 'Hervorhebt die Anzahl der kritischen, blockierenden Probleme, die derzeit in Jira offen sind.', 'metric.github.files_check.title': 'GitHub-Dateiprüfung: {{name}}', 'metric.github.files_check.description': - 'Prüft, ob die Datei {{name}} im Repository vorhanden ist.', + 'Prüft, ob die Datei {{name}} im Repository vorhanden ist.', 'metric.lastUpdated': 'Zuletzt aktualisiert: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Zuletzt aktualisiert: Nicht verfügbar', 'metric.someEntitiesNotReportingValues': From 5e2197243ec26a3b3690d1e1c91fbf0d354eae52 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Tue, 14 Apr 2026 15:35:31 +0200 Subject: [PATCH 10/20] ref: fix sonarqube issues Signed-off-by: Diana Janickova --- .../src/github/GithubClient.ts | 2 +- .../GithubFilesProvider.test.ts | 2 +- .../__fixtures__/mockProviders.ts | 36 +++++++++---------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index 6bf9803840..c703be4928 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -83,7 +83,7 @@ export class GithubClient { * eg. "github.files_check.readme-correct" -> "github_files_check_readme_correct" */ private sanitizeGraphQLAlias(alias: string): string { - return alias.replace(/[^_0-9A-Za-z]/g, '_'); + return alias.replaceAll(/\W/g, '_'); } async checkFilesExist( diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts index 3313150042..7d1bb9b763 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -71,7 +71,7 @@ describe('GithubFilesProvider', () => { const provider = GithubFilesProvider.fromConfig(config); expect(provider).toBeDefined(); - expect(provider!.getMetricIds()).toEqual([ + expect(provider?.getMetricIds()).toEqual([ 'github.files_check.readme', 'github.files_check.license', ]); diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts index d1b47a532b..e8fa3801e5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts @@ -24,6 +24,20 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +const BOOLEAN_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '==true' }, + { key: 'error', expression: '==false' }, + ], +}; + +const MOCK_CATALOG_FILTER: Record< + string, + string | symbol | (string | symbol)[] +> = { + 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, +}; + abstract class MockMetricProvider implements MetricProvider { @@ -39,9 +53,7 @@ abstract class MockMetricProvider abstract getMetricThresholds(): ThresholdConfig; getCatalogFilter(): Record { - return { - 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, - }; + return MOCK_CATALOG_FILTER; } getProviderDatasourceId(): string { @@ -107,12 +119,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( @@ -184,18 +191,11 @@ export class MockBatchBooleanProvider implements MetricProvider<'boolean'> { } getMetricThresholds(): ThresholdConfig { - return { - rules: [ - { key: 'success', expression: '==true' }, - { key: 'error', expression: '==false' }, - ], - }; + return BOOLEAN_THRESHOLDS; } getCatalogFilter(): Record { - return { - 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, - }; + return MOCK_CATALOG_FILTER; } async calculateMetric(_entity: Entity): Promise { From 768ad4d264a08e68e6050aee5d9c72933ce8e320 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 10:07:04 +0200 Subject: [PATCH 11/20] fix: broken test Signed-off-by: Diana Janickova --- .../plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 888c8b57b6..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,6 @@ */ import type { Theme } from '@mui/material/styles'; -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { DEFAULT_NUMBER_THRESHOLDS, @@ -172,7 +171,7 @@ describe('statusUtils', () => { expect(result).toEqual({ color: 'success.main', - icon: CheckCircleOutlineIcon, + icon: 'scorecardSuccessStatusIcon', }); }); From ae8c0a8064f9b731df5772dab9d8d0c254be2a28 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 10:42:22 +0200 Subject: [PATCH 12/20] feat: show aggregated cards for file checks Signed-off-by: Diana Janickova --- .../src/alpha/extensions/homePageCards.tsx | 50 +++++++++++++++++++ .../plugins/scorecard/src/alpha/index.tsx | 4 ++ 2 files changed, 54 insertions(+) diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index 9d6b0530a9..eacd8302af 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -47,6 +47,14 @@ function AggregatedCardWithGithubOpenPrsContent() { return ; } +function AggregatedCardWithGithubFilesCheckReadmeContent() { + return ; +} + +function AggregatedCardWithGithubFilesCheckI18guideContent() { + return ; +} + function BorderlessHomeWidgetRenderer({ Content }: RendererProps) { return ; } @@ -134,3 +142,45 @@ export const aggregatedCardWithGithubOpenPrsWidget = }), }, }); + +/** + * NFS widget: AggregatedCardWithGithubFilesCheckReadme. + * @alpha + */ +export const aggregatedCardWithGithubFilesCheckReadmeWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-files-check-readme', + params: { + name: 'AggregatedCardWithGithubFilesCheckReadme', + title: 'Scorecard: README file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilesCheckReadmeContent, + }), + }, + }); + +/** + * NFS widget: AggregatedCardWithGithubFilesCheckI18guide. + * @alpha + */ +export const aggregatedCardWithGithubFilesCheckI18guideWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-files-check-i18guide', + params: { + name: 'AggregatedCardWithGithubFilesCheckI18guide', + title: 'Scorecard: i18n guide file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilesCheckI18guideContent, + }), + }, + }); diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx index cdda2a9fc8..ac18a5d229 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, + aggregatedCardWithGithubFilesCheckReadmeWidget, + aggregatedCardWithGithubFilesCheckI18guideWidget, } from './extensions/homePageCards'; import { scorecardPage } from './extensions/scorecardPage'; @@ -82,6 +84,8 @@ export const scorecardHomeModule = createFrontendModule({ aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithJiraOpenIssuesWidget, aggregatedCardWithGithubOpenPrsWidget, + aggregatedCardWithGithubFilesCheckReadmeWidget, + aggregatedCardWithGithubFilesCheckI18guideWidget, ], }); From 554dd8a4ec1ece3c973babbc37fb5a56b1036db7 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 10:46:26 +0200 Subject: [PATCH 13/20] ref: fix sonarqube issue Signed-off-by: Diana Janickova --- .../scorecard-backend/__fixtures__/mockProviders.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts index e8fa3801e5..1724aab623 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts @@ -24,6 +24,8 @@ 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' }, @@ -31,10 +33,7 @@ const BOOLEAN_THRESHOLDS: ThresholdConfig = { ], }; -const MOCK_CATALOG_FILTER: Record< - string, - string | symbol | (string | symbol)[] -> = { +const MOCK_CATALOG_FILTER: Record = { 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, }; @@ -52,7 +51,7 @@ abstract class MockMetricProvider abstract getMetricThresholds(): ThresholdConfig; - getCatalogFilter(): Record { + getCatalogFilter(): Record { return MOCK_CATALOG_FILTER; } @@ -194,7 +193,7 @@ export class MockBatchBooleanProvider implements MetricProvider<'boolean'> { return BOOLEAN_THRESHOLDS; } - getCatalogFilter(): Record { + getCatalogFilter(): Record { return MOCK_CATALOG_FILTER; } From 4c04759a0a863e9f95d8fc9ab8e983386db2a921 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 11:02:10 +0200 Subject: [PATCH 14/20] chore: generate changeset, update readme Signed-off-by: Diana Janickova --- workspaces/scorecard/.changeset/big-yaks-say.md | 8 ++++++++ .../scorecard/plugins/scorecard-backend/README.md | 13 +++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 workspaces/scorecard/.changeset/big-yaks-say.md diff --git a/workspaces/scorecard/.changeset/big-yaks-say.md b/workspaces/scorecard/.changeset/big-yaks-say.md new file mode 100644 index 0000000000..ee80dd1356 --- /dev/null +++ b/workspaces/scorecard/.changeset/big-yaks-say.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github': 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 configurable GitHub file existence checks (github.files_check.\*) that verify whether required files (like README, LICENSE, or CODEOWNERS) are present in a repository. diff --git a/workspaces/scorecard/plugins/scorecard-backend/README.md b/workspaces/scorecard/plugins/scorecard-backend/README.md index 69eeb599ca..deec134b69 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 | +| **GitHub** | `github.files_check.*` | GitHub 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: From 87d44b2ff8379076d4641e804cae5fd9a8c48efd Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 13:19:41 +0200 Subject: [PATCH 15/20] ref: use license and codeowners as examples Signed-off-by: Diana Janickova --- workspaces/scorecard/app-config.yaml | 4 +-- .../scorecard/packages/app-legacy/src/App.tsx | 24 ++++++------- .../src/alpha/extensions/homePageCards.tsx | 34 ++++++++++--------- .../plugins/scorecard/src/alpha/index.tsx | 8 ++--- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index d2d6b7bbad..c61c09315e 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -191,8 +191,8 @@ scorecard: initialDelay: { seconds: 5 } files_check: files: - - readme: 'README.md' - - i18guide: 'docs/backstage-i18n-guide.md' + - license: 'LICENSE' + - codeowners: 'CODEOWNERS' schedule: frequency: { minutes: 5 } timeout: { minutes: 10 } diff --git a/workspaces/scorecard/packages/app-legacy/src/App.tsx b/workspaces/scorecard/packages/app-legacy/src/App.tsx index 50f83d7a93..d9ea49d404 100644 --- a/workspaces/scorecard/packages/app-legacy/src/App.tsx +++ b/workspaces/scorecard/packages/app-legacy/src/App.tsx @@ -220,8 +220,8 @@ const mountPoints: HomePageCardMountPoint[] = [ { Component: ScorecardHomepageCard as ComponentType, config: { - id: 'scorecard-github.files_check.readme', - title: 'Scorecard: README file exists', + id: 'scorecard-github.files_check.license', + title: 'Scorecard: LICENSE file exists', cardLayout: { width: { minColumns: 3, @@ -243,15 +243,15 @@ const mountPoints: HomePageCardMountPoint[] = [ xxs: { w: 4, h: 6 }, }, props: { - aggregationId: 'github.files_check.readme', + aggregationId: 'github.files_check.license', }, }, }, { Component: ScorecardHomepageCard as ComponentType, config: { - id: 'scorecard-github.files_check.i18guide', - title: 'Scorecard: i18n guide file exists', + id: 'scorecard-github.files_check.codeowners', + title: 'Scorecard: CODEOWNERS file exists', cardLayout: { width: { minColumns: 3, @@ -273,7 +273,7 @@ const mountPoints: HomePageCardMountPoint[] = [ xxs: { w: 4, h: 6, x: 4 }, }, props: { - aggregationId: 'github.files_check.i18guide', + aggregationId: 'github.files_check.codeowners', }, }, }, @@ -310,12 +310,11 @@ const mountPoints: HomePageCardMountPoint[] = [ metricId: { title: 'Metric (Needs currently a page reload after change!)', type: 'string', - default: 'jira.open_issues', + default: 'github.open_prs', enum: [ - 'jira.open_issues', 'github.open_prs', - 'github.files_check.readme', - 'github.files_check.i18guide', + 'github.files_check.license', + 'github.files_check.codeowners', ], }, }, @@ -324,10 +323,9 @@ const mountPoints: HomePageCardMountPoint[] = [ metricId: { 'ui:widget': 'RadioWidget', 'ui:enumNames': [ - 'Jira Open Issues', 'GitHub Open PRs', - 'README file exists', - 'i18n guide file exists', + 'LICENSE file exists', + 'CODEOWNERS file exists', ], }, }, diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index eacd8302af..83b3a895ec 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -47,12 +47,14 @@ function AggregatedCardWithGithubOpenPrsContent() { return ; } -function AggregatedCardWithGithubFilesCheckReadmeContent() { - return ; +function AggregatedCardWithGithubFilesCheckLicenseContent() { + return ; } -function AggregatedCardWithGithubFilesCheckI18guideContent() { - return ; +function AggregatedCardWithGithubFilesCheckCodeownersContent() { + return ( + + ); } function BorderlessHomeWidgetRenderer({ Content }: RendererProps) { @@ -144,43 +146,43 @@ export const aggregatedCardWithGithubOpenPrsWidget = }); /** - * NFS widget: AggregatedCardWithGithubFilesCheckReadme. + * NFS widget: AggregatedCardWithGithubFilesCheckLicense. * @alpha */ -export const aggregatedCardWithGithubFilesCheckReadmeWidget = +export const aggregatedCardWithGithubFilesCheckLicenseWidget = HomePageWidgetBlueprint.make({ - name: 'scorecard-github-files-check-readme', + name: 'scorecard-github-files-check-license', params: { - name: 'AggregatedCardWithGithubFilesCheckReadme', - title: 'Scorecard: README file exists', + name: 'AggregatedCardWithGithubFilesCheckLicense', + title: 'Scorecard: LICENSE file exists', layout: defaultCardLayout, componentProps: { Renderer: BorderlessHomeWidgetRenderer, }, components: () => Promise.resolve({ - Content: AggregatedCardWithGithubFilesCheckReadmeContent, + Content: AggregatedCardWithGithubFilesCheckLicenseContent, }), }, }); /** - * NFS widget: AggregatedCardWithGithubFilesCheckI18guide. + * NFS widget: AggregatedCardWithGithubFilesCheckCodeowners. * @alpha */ -export const aggregatedCardWithGithubFilesCheckI18guideWidget = +export const aggregatedCardWithGithubFilesCheckCodeownersWidget = HomePageWidgetBlueprint.make({ - name: 'scorecard-github-files-check-i18guide', + name: 'scorecard-github-files-check-codeowners', params: { - name: 'AggregatedCardWithGithubFilesCheckI18guide', - title: 'Scorecard: i18n guide file exists', + name: 'AggregatedCardWithGithubFilesCheckCodeowners', + title: 'Scorecard: CODEOWNERS file exists', layout: defaultCardLayout, componentProps: { Renderer: BorderlessHomeWidgetRenderer, }, components: () => Promise.resolve({ - Content: AggregatedCardWithGithubFilesCheckI18guideContent, + Content: AggregatedCardWithGithubFilesCheckCodeownersContent, }), }, }); diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx index ac18a5d229..db9250656e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx @@ -28,8 +28,8 @@ import { aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithGithubOpenPrsWidget, aggregatedCardWithJiraOpenIssuesWidget, - aggregatedCardWithGithubFilesCheckReadmeWidget, - aggregatedCardWithGithubFilesCheckI18guideWidget, + aggregatedCardWithGithubFilesCheckLicenseWidget, + aggregatedCardWithGithubFilesCheckCodeownersWidget, } from './extensions/homePageCards'; import { scorecardPage } from './extensions/scorecardPage'; @@ -84,8 +84,8 @@ export const scorecardHomeModule = createFrontendModule({ aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithJiraOpenIssuesWidget, aggregatedCardWithGithubOpenPrsWidget, - aggregatedCardWithGithubFilesCheckReadmeWidget, - aggregatedCardWithGithubFilesCheckI18guideWidget, + aggregatedCardWithGithubFilesCheckLicenseWidget, + aggregatedCardWithGithubFilesCheckCodeownersWidget, ], }); From 3383dfa35b2163390959ffaba8ee1eea880faff3 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 13:54:26 +0200 Subject: [PATCH 16/20] fix: implement qodo suggestions Signed-off-by: Diana Janickova --- .../src/github/GitHubClient.test.ts | 70 +++++++++ .../src/github/GithubClient.ts | 42 ++++-- .../GithubFilesProvider.test.ts | 90 +++++++++++ .../metricProviders/GithubFilesProvider.ts | 16 ++ .../tasks/PullMetricsByProviderTask.test.ts | 142 ++++++++++++++++++ .../tasks/PullMetricsByProviderTask.ts | 30 ++-- 6 files changed, 364 insertions(+), 26 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts index 376ab5d376..217b5e97d8 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts @@ -161,6 +161,76 @@ describe('GithubClient', () => { expect(result.size).toBe(0); }); + it('should safely escape paths containing quotes in the GraphQL query', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map([ + ['github.files_check.tricky', 'path/with"quote.txt'], + ]); + + const response = { + repository: { + github_files_check_tricky: { id: 'abc' }, + }, + }; + mockedGraphqlClient.mockResolvedValue(response); + + await githubClient.checkFilesExist(url, repository, files); + + const queryArg = mockedGraphqlClient.mock.calls[0][0] as string; + expect(queryArg).toContain( + 'object(expression: "HEAD:path/with\\"quote.txt")', + ); + expect(queryArg).not.toContain('object(expression: "HEAD:path/with"'); + }); + + it('should safely escape paths containing newlines in the GraphQL query', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map([ + ['github.files_check.newline', 'path/with\nnewline.txt'], + ]); + + const response = { + repository: { + github_files_check_newline: null, + }, + }; + mockedGraphqlClient.mockResolvedValue(response); + + await githubClient.checkFilesExist(url, repository, files); + + const queryArg = mockedGraphqlClient.mock.calls[0][0] as string; + expect(queryArg).toContain( + 'object(expression: "HEAD:path/with\\nnewline.txt")', + ); + }); + + it('should handle alias collisions from metric IDs that differ only in non-word characters', async () => { + const url = 'https://github.com/owner/repo'; + const files = new Map([ + ['github.files_check.read-me', 'READ-ME.md'], + ['github.files_check.read_me', 'README.md'], + ]); + + mockedGraphqlClient.mockImplementation(async (query: string) => { + const hasDeduplicatedAlias = query.includes( + 'github_files_check_read_me__2', + ); + expect(hasDeduplicatedAlias).toBe(true); + return { + repository: { + github_files_check_read_me: null, + github_files_check_read_me__2: { id: 'abc123' }, + }, + }; + }); + + const result = await githubClient.checkFilesExist(url, repository, files); + + expect(result.size).toBe(2); + expect(result.get('github.files_check.read-me')).toBe(false); + expect(result.get('github.files_check.read_me')).toBe(true); + }); + it('should throw error when GitHub integration for URL is missing', async () => { const unknownUrl = 'https://unknown-host/owner/repo'; const files = new Map([ diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index c703be4928..1ad64bd2ad 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -79,11 +79,34 @@ export class GithubClient { } /** - * Sanitize a string to be a valid GraphQL alias. - * eg. "github.files_check.readme-correct" -> "github_files_check_readme_correct" + * Build a unique GraphQL alias for each metric ID. + * + * GraphQL aliases must be valid identifiers (only [a-zA-Z0-9_]) and unique + * within a query. Metric IDs are sanitized by replacing non-word characters + * with underscores, but this is lossy — e.g. "read-me" and "read_me" both + * become "read_me". To avoid alias collisions (which would cause silent data + * loss), a numeric suffix is appended when a duplicate is detected. */ - private sanitizeGraphQLAlias(alias: string): string { - return alias.replaceAll(/\W/g, '_'); + private buildUniqueAliases(metricIds: string[]): Map { + const aliasToMetricId = new Map(); + const usedAliases = new Set(); + + for (const metricId of metricIds) { + let alias = metricId.replaceAll(/\W/g, '_'); + + if (usedAliases.has(alias)) { + let counter = 2; + while (usedAliases.has(`${alias}__${counter}`)) { + counter++; + } + alias = `${alias}__${counter}`; + } + + usedAliases.add(alias); + aliasToMetricId.set(alias, metricId); + } + + return aliasToMetricId; } async checkFilesExist( @@ -93,15 +116,14 @@ export class GithubClient { ): Promise> { const octokit = await this.getOctokitClient(url); - const aliasToMetricId = new Map(); + const aliasToMetricId = this.buildUniqueAliases([...files.keys()]); const fileChecksParts: string[] = []; - for (const [metricId, path] of files) { - const sanitizedAlias = this.sanitizeGraphQLAlias(metricId); - - aliasToMetricId.set(sanitizedAlias, metricId); + for (const [alias, metricId] of aliasToMetricId) { + const path = files.get(metricId)!; + const expr = `HEAD:${path}`; fileChecksParts.push( - `${sanitizedAlias}: object(expression: "HEAD:${path}") { id }`, + `${alias}: object(expression: ${JSON.stringify(expr)}) { id }`, ); } diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts index 7d1bb9b763..a9e1b8c179 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -77,6 +77,96 @@ describe('GithubFilesProvider', () => { ]); }); + it('should throw error when file path contains a double quote', () => { + const config = new ConfigReader({ + scorecard: { + plugins: { + github: { + files_check: { + files: [{ bad: 'path/with"quote.txt' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(config)).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: { + github: { + files_check: { + files: [{ bad: 'path/with\nnewline' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(config)).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: { + github: { + files_check: { + files: [{ bad: 'path\\file.txt' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(config)).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: { + github: { + files_check: { + files: [{ bad: '/absolute/path.txt' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(config)).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: { + github: { + files_check: { + files: [{ bad: './relative/path.txt' }], + }, + }, + }, + }, + }); + + expect(() => GithubFilesProvider.fromConfig(config)).toThrow( + "Invalid file path for 'bad': path must be relative without leading './' or '/'", + ); + }); + it('should throw error when file config entry has multiple key-value pairs', () => { const invalidConfig = new ConfigReader({ scorecard: { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts index 16ed30b076..9a303a0b02 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts @@ -27,6 +27,21 @@ import { GithubClient } from '../github/GithubClient'; import { getRepositoryInformationFromEntity } from '../github/utils'; import { GithubFile, GithubFilesConfig } from '../github/types'; +const INVALID_PATH_CHARS = /[\n\r"\\]/; + +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('./')) { + throw new Error( + `Invalid file path for '${id}': path must be relative without leading './' or '/'`, + ); + } +} + export class GithubFilesProvider implements MetricProvider<'boolean'> { private readonly githubClient: GithubClient; private readonly thresholds: ThresholdConfig; @@ -123,6 +138,7 @@ export class GithubFilesProvider implements MetricProvider<'boolean'> { } const id = keys[0]; const path = fileConfig.getString(id); + validateFilePath(id, path); return { id, path }; }); 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 fc3054dbf4..cceb6aebf3 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 @@ -600,6 +600,148 @@ describe('PullMetricsByProviderTask', () => { ); }); + 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': + 'github.files_check.readme,github.files_check.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': 'github.files_check.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: 'github.files_check.readme', + value: true, + }), + ]); + }); + + it('should skip batch metrics disabled via scorecard.disabledMetrics app-config', async () => { + const configWithDisabled = mockServices.rootConfig({ + data: { + scorecard: { + schedule: scheduleConfig, + disabledMetrics: ['github.files_check.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('github.files_check.license'); + expect(metricIds).toContain('github.files_check.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': 'github.files_check.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: 'github.files_check.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() 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 9cf3183b66..9dca4b07b5 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.ts @@ -150,11 +150,19 @@ export class PullMetricsByProviderTask implements SchedulerTask { 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); - // Create a result for each metric ID - return metricIds.map(metricId => { + return enabledMetricIds.map(metricId => { const value = resultsMap.get(metricId)!; try { @@ -195,8 +203,7 @@ export class PullMetricsByProviderTask implements SchedulerTask { } }); } catch (error) { - // If batch calculation fails, create error records for all metrics - return metricIds.map( + return enabledMetricIds.map( metricId => ({ catalog_entity_ref: entityRef, @@ -280,19 +287,10 @@ export class PullMetricsByProviderTask implements SchedulerTask { }, [] as DbMetricValueCreate[]), ); - // Log summary of batch results for debugging, will remove before final PR if (batchResults.length > 0) { - const summary = batchResults.map(r => ({ - entity: r.catalog_entity_ref, - metric: r.metric_id, - value: r.value, - status: r.status, - ...(r.error_message && { error: r.error_message }), - })); - logger.info( - `Storing ${batchResults.length} metric values: ${JSON.stringify( - summary, - )}`, + const errorCount = batchResults.filter(r => r.error_message).length; + logger.debug( + `Storing ${batchResults.length} metric values (${errorCount} errors)`, ); } From 56289e177df2996d09f57d6723a55f39100d9fb4 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 13:57:52 +0200 Subject: [PATCH 17/20] fix: return empty map for no files Signed-off-by: Diana Janickova --- .../src/github/GitHubClient.test.ts | 6 +----- .../src/github/GithubClient.ts | 2 ++ 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts index 217b5e97d8..1cda339802 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts @@ -151,14 +151,10 @@ describe('GithubClient', () => { const url = 'https://github.com/owner/repo'; const files = new Map(); - const response = { - repository: {}, - }; - mockedGraphqlClient.mockResolvedValue(response); - const result = await githubClient.checkFilesExist(url, repository, files); expect(result.size).toBe(0); + expect(mockedGraphqlClient).not.toHaveBeenCalled(); }); it('should safely escape paths containing quotes in the GraphQL query', async () => { diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index 1ad64bd2ad..09154ed358 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -114,6 +114,8 @@ export class GithubClient { repository: GithubRepository, files: Map, ): Promise> { + if (files.size === 0) return new Map(); + const octokit = await this.getOctokitClient(url); const aliasToMetricId = this.buildUniqueAliases([...files.keys()]); From 4bbe47419d418ee0fcd530e3bb2468e2680f56e2 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Wed, 15 Apr 2026 14:43:19 +0200 Subject: [PATCH 18/20] ref: fix sonarqube issues Signed-off-by: Diana Janickova --- .../src/github/GitHubClient.test.ts | 4 ++-- .../src/github/GithubClient.ts | 3 ++- .../src/metricProviders/GithubFilesProvider.test.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts index 1cda339802..15d0f7ec5d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GitHubClient.test.ts @@ -174,7 +174,7 @@ describe('GithubClient', () => { const queryArg = mockedGraphqlClient.mock.calls[0][0] as string; expect(queryArg).toContain( - 'object(expression: "HEAD:path/with\\"quote.txt")', + String.raw`object(expression: "HEAD:path/with\"quote.txt")`, ); expect(queryArg).not.toContain('object(expression: "HEAD:path/with"'); }); @@ -196,7 +196,7 @@ describe('GithubClient', () => { const queryArg = mockedGraphqlClient.mock.calls[0][0] as string; expect(queryArg).toContain( - 'object(expression: "HEAD:path/with\\nnewline.txt")', + String.raw`object(expression: "HEAD:path/with\nnewline.txt")`, ); }); diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts index 09154ed358..1757ff38a6 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/github/GithubClient.ts @@ -122,7 +122,8 @@ export class GithubClient { const fileChecksParts: string[] = []; for (const [alias, metricId] of aliasToMetricId) { - const path = files.get(metricId)!; + const path = files.get(metricId); + if (!path) continue; const expr = `HEAD:${path}`; fileChecksParts.push( `${alias}: object(expression: ${JSON.stringify(expr)}) { id }`, diff --git a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts index a9e1b8c179..da5589c1ea 100644 --- a/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -119,7 +119,7 @@ describe('GithubFilesProvider', () => { plugins: { github: { files_check: { - files: [{ bad: 'path\\file.txt' }], + files: [{ bad: String.raw`path\file.txt` }], }, }, }, From 9b4f0d198297c9f5d6178c54eb5967dfb8385fa8 Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Thu, 16 Apr 2026 14:45:57 +0200 Subject: [PATCH 19/20] feat: add aggregation card with custom title Signed-off-by: Diana Janickova --- workspaces/scorecard/app-config.yaml | 5 +++++ .../plugins/scorecard/src/alpha/extensions/homePageCards.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/workspaces/scorecard/app-config.yaml b/workspaces/scorecard/app-config.yaml index c61c09315e..327bb62dfa 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -176,6 +176,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: github.files_check.license plugins: jira: open_issues: diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index 83b3a895ec..8a6be0c4bd 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -48,7 +48,7 @@ function AggregatedCardWithGithubOpenPrsContent() { } function AggregatedCardWithGithubFilesCheckLicenseContent() { - return ; + return ; } function AggregatedCardWithGithubFilesCheckCodeownersContent() { From 7df0227fd5501c6ac611f1fc75306022f4cd165e Mon Sep 17 00:00:00 2001 From: Diana Janickova Date: Mon, 20 Apr 2026 16:20:48 +0200 Subject: [PATCH 20/20] fix: use ScorecardHomepageCardWithProvider Signed-off-by: Diana Janickova --- .../scorecard/src/alpha/extensions/homePageCards.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index a51da22502..4e4a631b96 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -48,12 +48,14 @@ function AggregatedCardWithGithubOpenPrsContent() { } function AggregatedCardWithGithubFilesCheckLicenseContent() { - return ; + return ( + + ); } function AggregatedCardWithGithubFilesCheckCodeownersContent() { return ( - + ); }