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/app-config.yaml b/workspaces/scorecard/app-config.yaml index 0905992eb4..fb53d1e418 100644 --- a/workspaces/scorecard/app-config.yaml +++ b/workspaces/scorecard/app-config.yaml @@ -198,6 +198,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: @@ -211,3 +216,11 @@ scorecard: frequency: { minutes: 5 } timeout: { minutes: 10 } initialDelay: { seconds: 5 } + files_check: + files: + - license: 'LICENSE' + - codeowners: 'CODEOWNERS' + schedule: + frequency: { minutes: 5 } + timeout: { minutes: 10 } + initialDelay: { seconds: 5 } diff --git a/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts b/workspaces/scorecard/packages/app-legacy/e2e-tests/scorecard.test.ts index 26ed91642c..8dfae84323 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..d9ea49d404 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.license', + title: 'Scorecard: LICENSE file exists', + cardLayout: { + width: { + minColumns: 3, + maxColumns: 12, + defaultColumns: 4, + }, + height: { + minRows: 5, + maxRows: 12, + defaultRows: 6, + }, + }, + layouts: { + xl: { w: 4, h: 6 }, + lg: { w: 4, h: 6 }, + md: { w: 4, h: 6 }, + sm: { w: 4, h: 6 }, + xs: { w: 4, h: 6 }, + xxs: { w: 4, h: 6 }, + }, + props: { + aggregationId: 'github.files_check.license', + }, + }, + }, + { + Component: ScorecardHomepageCard as ComponentType, + config: { + id: 'scorecard-github.files_check.codeowners', + title: 'Scorecard: CODEOWNERS file exists', + cardLayout: { + width: { + minColumns: 3, + maxColumns: 12, + defaultColumns: 4, + }, + height: { + minRows: 5, + maxRows: 12, + defaultRows: 6, + }, + }, + layouts: { + xl: { w: 4, h: 6, x: 4 }, + lg: { w: 4, h: 6, x: 4 }, + md: { w: 4, h: 6, x: 4 }, + sm: { w: 4, h: 6, x: 4 }, + xs: { w: 4, h: 6, x: 4 }, + xxs: { w: 4, h: 6, x: 4 }, + }, + props: { + aggregationId: 'github.files_check.codeowners', + }, + }, + }, { Component: ScorecardHomepageCard as ComponentType, config: { @@ -250,15 +310,23 @@ const mountPoints: HomePageCardMountPoint[] = [ metricId: { title: 'Metric (Needs currently a page reload after change!)', type: 'string', - default: 'jira.open_issues', - enum: ['jira.open_issues', 'github.open_prs'], + default: 'github.open_prs', + enum: [ + 'github.open_prs', + 'github.files_check.license', + 'github.files_check.codeowners', + ], }, }, }, uiSchema: { metricId: { 'ui:widget': 'RadioWidget', - 'ui:enumNames': ['Jira Open Issues', 'GitHub Open PRs'], + 'ui:enumNames': [ + 'GitHub Open PRs', + 'LICENSE file exists', + 'CODEOWNERS file exists', + ], }, }, }, 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..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 @@ -93,4 +93,149 @@ 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 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 () => { + 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( + String.raw`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( + String.raw`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([ + ['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..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 @@ -77,4 +77,81 @@ export class GithubClient { return response.repository.pullRequests.totalCount; } + + /** + * 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 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( + url: string, + repository: GithubRepository, + files: Map, + ): Promise> { + if (files.size === 0) return new Map(); + + const octokit = await this.getOctokitClient(url); + + const aliasToMetricId = this.buildUniqueAliases([...files.keys()]); + const fileChecksParts: string[] = []; + + for (const [alias, metricId] of aliasToMetricId) { + const path = files.get(metricId); + if (!path) continue; + const expr = `HEAD:${path}`; + fileChecksParts.push( + `${alias}: object(expression: ${JSON.stringify(expr)}) { 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/GithubConfig.ts b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts new file mode 100644 index 0000000000..8a6d975ded --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubConfig.ts @@ -0,0 +1,37 @@ +/* + * 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, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], +}; 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..da5589c1ea --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.test.ts @@ -0,0 +1,355 @@ +/* + * 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_FILE_CHECK_THRESHOLDS } from './GithubConfig'; + +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 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: String.raw`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: { + 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 file check thresholds', () => { + expect(provider.getMetricThresholds()).toEqual( + DEFAULT_FILE_CHECK_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..9a303a0b02 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend-module-github/src/metricProviders/GithubFilesProvider.ts @@ -0,0 +1,147 @@ +/* + * 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 { + 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'; +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; + private readonly filesConfig: GithubFilesConfig; + + private constructor(config: Config, filesConfig: GithubFilesConfig) { + this.githubClient = new GithubClient(config); + this.filesConfig = filesConfig; + this.thresholds = DEFAULT_FILE_CHECK_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); + validateFilePath(id, path); + 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/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: diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts index 66043f0158..e067a394b3 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockMetricProvidersRegistry.ts @@ -52,6 +52,11 @@ export const buildMockMetricProvidersRegistry = ({ const getMetric = provider || metricsList ? jest.fn().mockImplementation((metricId: string) => { + if (provider?.getMetrics) { + const found = provider.getMetrics().find(m => m.id === metricId); + if (found) return found; + } + const pMetric = provider?.getMetric(); if (pMetric && pMetric.id === metricId) return pMetric; @@ -67,7 +72,7 @@ export const buildMockMetricProvidersRegistry = ({ return { ...mockMetricProvidersRegistry, getProvider, - listMetrics, getMetric, + listMetrics, } as unknown as jest.Mocked; }; diff --git a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts index 5408ec8b32..1724aab623 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/__fixtures__/mockProviders.ts @@ -24,6 +24,19 @@ import { } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +type CatalogFilterValue = string | symbol | (string | symbol)[]; + +const BOOLEAN_THRESHOLDS: ThresholdConfig = { + rules: [ + { key: 'success', expression: '==true' }, + { key: 'error', expression: '==false' }, + ], +}; + +const MOCK_CATALOG_FILTER: Record = { + 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, +}; + abstract class MockMetricProvider implements MetricProvider { @@ -38,10 +51,8 @@ abstract class MockMetricProvider abstract getMetricThresholds(): ThresholdConfig; - getCatalogFilter(): Record { - return { - 'metadata.annotations.mock/key': CATALOG_FILTER_EXISTS, - }; + getCatalogFilter(): Record { + return MOCK_CATALOG_FILTER; } getProviderDatasourceId(): string { @@ -107,12 +118,7 @@ export class MockBooleanProvider extends MockMetricProvider<'boolean'> { super('boolean', providerId, datasourceId, title, description, value); } getMetricThresholds(): ThresholdConfig { - return { - rules: [ - { key: 'success', expression: '==true' }, - { key: 'error', expression: '==false' }, - ], - }; + return BOOLEAN_THRESHOLDS; } } export const githubNumberProvider = new MockNumberProvider( @@ -139,3 +145,99 @@ export const jiraBooleanMetricMetadata = { description: 'Mock boolean description.', type: 'boolean' as const, }; + +/** + * Mock batch provider that exposes multiple metrics + */ +export class MockBatchBooleanProvider implements MetricProvider<'boolean'> { + private readonly metricConfigs: Array<{ id: string; path: string }>; + + constructor( + private readonly datasourceId: string, + private readonly providerIdPrefix: string, + metricConfigs: Array<{ id: string; path: string }>, + ) { + this.metricConfigs = metricConfigs; + } + + getProviderDatasourceId(): string { + return this.datasourceId; + } + + getProviderId(): string { + return this.providerIdPrefix; + } + + getMetricType(): 'boolean' { + return 'boolean'; + } + + getMetricIds(): string[] { + return this.metricConfigs.map(c => `${this.providerIdPrefix}.${c.id}`); + } + + getMetrics(): Metric<'boolean'>[] { + return this.metricConfigs.map(c => ({ + id: `${this.providerIdPrefix}.${c.id}`, + title: `File: ${c.path}`, + description: `Checks if ${c.path} exists.`, + type: 'boolean' as const, + })); + } + + getMetric(): Metric<'boolean'> { + return this.getMetrics()[0]; + } + + getMetricThresholds(): ThresholdConfig { + return BOOLEAN_THRESHOLDS; + } + + getCatalogFilter(): Record { + return MOCK_CATALOG_FILTER; + } + + async calculateMetric(_entity: Entity): Promise { + const results = await this.calculateMetrics(_entity); + return results.get(this.getMetricIds()[0]) ?? false; + } + + async calculateMetrics(_entity: Entity): Promise> { + const results = new Map(); + for (const config of this.metricConfigs) { + results.set(`${this.providerIdPrefix}.${config.id}`, true); + } + return results; + } +} + +export const 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 e900d4c0d1..eeed50306a 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/providers/MetricProvidersRegistry.ts @@ -30,54 +30,66 @@ export class MetricProvidersRegistry { private readonly datasourceIndex = new Map>(); register(metricProvider: MetricProvider): void { - const providerId = metricProvider.getProviderId(); const providerDatasource = metricProvider.getProviderDatasourceId(); - const metric = metricProvider.getMetric(); const metricType = metricProvider.getMetricType(); - if (providerId !== metric.id) { - throw new Error( - `Invalid metric provider with ID ${providerId}, provider ID must match metric ID '${metric.id}'`, - ); - } + // Support both single and batch providers + const metricIds = metricProvider.getMetricIds?.() ?? [ + metricProvider.getProviderId(), + ]; + const metrics = metricProvider.getMetrics?.() ?? [ + metricProvider.getMetric(), + ]; + + // Validate: Each metric ID must have a corresponding metric definition + for (const metricId of metricIds) { + const metric = metrics.find(m => m.id === metricId); + if (!metric) { + throw new Error( + `Invalid metric provider: metric ID '${metricId}' returned by getMetricIds() ` + + `does not have a corresponding metric in getMetrics()`, + ); + } - if (metricType !== metric.type) { - throw new Error( - `Invalid metric provider with ID ${providerId}, getMetricType() must match getMetric().type. Expected '${metricType}', but got '${metric.type}'`, - ); - } + if (metricType !== metric.type) { + throw new Error( + `Invalid metric provider with ID ${metricId}, getMetricType() must match ` + + `getMetric().type. Expected '${metricType}', but got '${metric.type}'`, + ); + } - const expectedPrefix = `${providerDatasource}.`; - if ( - !providerId.startsWith(expectedPrefix) || - providerId === expectedPrefix - ) { - throw new Error( - `Invalid metric provider with ID ${providerId}, must have format '${providerDatasource}.' where metric name is not empty`, - ); - } + // Validate: Provider ID format (datasource.metric_name) + const expectedPrefix = `${providerDatasource}.`; + if (!metricId.startsWith(expectedPrefix) || metricId === expectedPrefix) { + throw new Error( + `Invalid metric provider with ID ${metricId}, must have format ` + + `'${providerDatasource}.' where metric name is not empty`, + ); + } - if (this.metricProviders.has(providerId)) { - throw new ConflictError( - `Metric provider with ID '${providerId}' has already been registered`, - ); - } + if (this.metricProviders.has(metricId)) { + throw new ConflictError( + `Metric provider with ID '${metricId}' has already been registered`, + ); + } - this.metricProviders.set(providerId, metricProvider); + this.metricProviders.set(metricId, metricProvider); - let datasourceProviders = this.datasourceIndex.get(providerDatasource); - if (!datasourceProviders) { - datasourceProviders = new Set(); - this.datasourceIndex.set(providerDatasource, datasourceProviders); + // Index by datasource + let datasourceProviders = this.datasourceIndex.get(providerDatasource); + if (!datasourceProviders) { + datasourceProviders = new Set(); + this.datasourceIndex.set(providerDatasource, datasourceProviders); + } + datasourceProviders.add(metricId); } - datasourceProviders.add(providerId); } - getProvider(providerId: string): MetricProvider { - const metricProvider = this.metricProviders.get(providerId); + getProvider(metricId: string): MetricProvider { + const metricProvider = this.metricProviders.get(metricId); if (!metricProvider) { throw new NotFoundError( - `Metric provider with ID '${providerId}' is not registered.`, + `No metric provider registered for metric ID '${metricId}'.`, ); } return metricProvider; @@ -87,46 +99,70 @@ export class MetricProvidersRegistry { return this.metricProviders.has(providerId); } - getMetric(providerId: string): Metric { - return this.getProvider(providerId).getMetric(); + getMetric(metricId: string): Metric { + const provider = this.getProvider(metricId); + + // For batch providers, find the specific metric by ID + if (provider.getMetrics) { + const metrics = provider.getMetrics(); + const metric = metrics.find(m => m.id === metricId); + if (metric) { + return metric; + } + } + + return provider.getMetric(); } async calculateMetric( - providerId: string, + metricId: string, entity: Entity, ): Promise { - return this.getProvider(providerId).calculateMetric(entity); + return this.getProvider(metricId).calculateMetric(entity); } async calculateMetrics( - providerIds: string[], + metricIds: string[], entity: Entity, - ): Promise<{ providerId: string; value?: MetricValue; error?: Error }[]> { + ): Promise<{ metricId: string; value?: MetricValue; error?: Error }[]> { const results = await Promise.allSettled( - providerIds.map(providerId => this.calculateMetric(providerId, entity)), + metricIds.map(metricId => this.calculateMetric(metricId, entity)), ); return results.map((result, index) => { - const providerId = providerIds[index]; + const metricId = metricIds[index]; if (result.status === 'fulfilled') { - return { providerId, value: result.value }; + return { metricId, value: result.value }; } - return { providerId, error: result.reason as Error }; + return { metricId, error: result.reason as Error }; }); } listProviders(): MetricProvider[] { - return Array.from(this.metricProviders.values()); + // Deduplicate providers since batch providers are stored under multiple metric IDs + return [...new Set(this.metricProviders.values())]; } - listMetrics(providerIds?: string[]): Metric[] { - if (providerIds && providerIds.length !== 0) { - return providerIds - .map(providerId => this.metricProviders.get(providerId)?.getMetric()) + listMetrics(metricIds?: string[]): Metric[] { + if (metricIds && metricIds.length !== 0) { + return metricIds + .map(metricId => { + const provider = this.metricProviders.get(metricId); + if (!provider) return undefined; + + if (provider.getMetrics) { + const metrics = provider.getMetrics(); + return metrics.find(m => m.id === metricId); + } + + return provider.getMetric(); + }) .filter((m): m is Metric => m !== undefined); } - return [...this.metricProviders.values()].map(provider => - provider.getMetric(), + + // List all metrics from all providers (deduplicate batch providers) + return this.listProviders().flatMap( + provider => provider.getMetrics?.() ?? [provider.getMetric()], ); } @@ -137,9 +173,13 @@ export class MetricProvidersRegistry { return []; } - return Array.from(providerIdsOfDatasource) - .map(providerId => this.metricProviders.get(providerId)) - .filter((provider): provider is MetricProvider => provider !== undefined) - .map(provider => provider.getMetric()); + // Get unique providers for this datasource, then get their metrics + const providers = [...providerIdsOfDatasource] + .map(id => this.metricProviders.get(id)) + .filter((p): p is MetricProvider => p !== undefined); + + return [...new Set(providers)].flatMap( + provider => provider.getMetrics?.() ?? [provider.getMetric()], + ); } } diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/scheduler/tasks/PullMetricsByProviderTask.test.ts index 47ec80b3a6..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 @@ -19,7 +19,10 @@ import { PullMetricsByProviderTask } from './PullMetricsByProviderTask'; import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; import { mergeEntityAndProviderThresholds } from '../../utils/mergeEntityAndProviderThresholds'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; -import { MockNumberProvider } from '../../../__fixtures__/mockProviders'; +import { + MockNumberProvider, + MockBatchBooleanProvider, +} from '../../../__fixtures__/mockProviders'; import type { Config } from '@backstage/config'; import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { mockDatabaseMetricValues } from '../../../__fixtures__/mockDatabaseMetricValues'; @@ -386,8 +389,7 @@ describe('PullMetricsByProviderTask', () => { it('should log completion', async () => { await (task as any).pullProviderMetrics(mockProvider, mockLogger); - expect(mockLogger.info).toHaveBeenNthCalledWith( - 2, + expect(mockLogger.info).toHaveBeenCalledWith( `Completed metric pull for github.test_metric: processed 2 entities`, ); }); @@ -434,5 +436,326 @@ describe('PullMetricsByProviderTask', () => { (task as any).pullProviderMetrics(mockProvider, mockLogger), ).rejects.toThrow('test error'); }); + + describe('batch providers', () => { + let mockBatchProvider: MockBatchBooleanProvider; + + beforeEach(() => { + mockBatchProvider = new MockBatchBooleanProvider( + '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 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() + .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..9dca4b07b5 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,83 @@ export class PullMetricsByProviderTask implements SchedulerTask { const batchResults = await Promise.allSettled( entitiesResponse.items.map(async entity => { + // Handle batch providers + if (isBatchProvider && provider.calculateMetrics) { + const entityRef = stringifyEntityRef(entity); + const entityKind = normalizeField(entity.kind); + const entityNamespace = normalizeField(entity.metadata.namespace); + const entityOwner = normalizeOwnerRef(entity?.spec?.owner); + + const enabledMetricIds = metricIds.filter( + metricId => + !isMetricIdDisabled(this.config, metricId, entity, logger), + ); + + if (enabledMetricIds.length === 0) { + return undefined; + } + + try { + const resultsMap = await provider.calculateMetrics(entity); + + return enabledMetricIds.map(metricId => { + const value = resultsMap.get(metricId)!; + + try { + const thresholds = mergeEntityAndProviderThresholds( + entity, + provider, + ); + + const status = + this.thresholdEvaluator.getFirstMatchingThreshold( + value, + metricType, + thresholds, + ); + + return { + catalog_entity_ref: entityRef, + metric_id: metricId, + value, + timestamp: new Date(), + status, + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate; + } catch (error) { + return { + catalog_entity_ref: entityRef, + metric_id: metricId, + value, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate; + } + }); + } catch (error) { + return enabledMetricIds.map( + metricId => + ({ + catalog_entity_ref: entityRef, + metric_id: metricId, + value: undefined, + timestamp: new Date(), + error_message: + error instanceof Error ? error.message : String(error), + entity_kind: entityKind, + entity_namespace: entityNamespace, + entity_owner: entityOwner, + } as DbMetricValueCreate), + ); + } + } + let value: MetricValue | undefined; try { @@ -197,12 +276,24 @@ export class PullMetricsByProviderTask implements SchedulerTask { ).then(promises => promises.reduce((acc, curr) => { if (curr.status === 'fulfilled' && curr.value !== undefined) { - return [...acc, curr.value]; + // Batch providers return an array of results, single providers return one result + const result = curr.value; + if (Array.isArray(result)) { + return [...acc, ...result]; + } + return [...acc, result]; } return acc; }, [] as DbMetricValueCreate[]), ); + if (batchResults.length > 0) { + const errorCount = batchResults.filter(r => r.error_message).length; + logger.debug( + `Storing ${batchResults.length} metric values (${errorCount} errors)`, + ); + } + await this.database.createMetricValues(batchResults); totalProcessed += entitiesResponse.items.length; } while (cursor !== undefined); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts index d7c7150ab2..eb3947cb6c 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.test.ts @@ -20,7 +20,11 @@ import { mockServices } from '@backstage/backend-test-utils'; import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; import { CatalogMetricService } from './CatalogMetricService'; import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; -import { MockNumberProvider } from '../../__fixtures__/mockProviders'; +import { + MockNumberProvider, + githubBatchProvider, + githubBatchMetrics, +} from '../../__fixtures__/mockProviders'; import { buildMockDatabaseMetricValues, mockDatabaseMetricValues, @@ -180,6 +184,11 @@ describe('CatalogMetricService', () => { mockedRegistry.getProvider.mockImplementation(id => id === 'github.important_metric' ? provider : secondProvider, ); + mockedRegistry.getMetric.mockImplementation(id => + id === 'github.important_metric' + ? provider.getMetric() + : secondProvider.getMetric(), + ); const multipleMetrics = [ { ...latestEntityMetric[0], metric_id: 'github.important_metric' }, @@ -294,14 +303,14 @@ describe('CatalogMetricService', () => { ); }); - it('should get provider metric', async () => { - const getMetricSpy = jest.spyOn(provider, 'getMetric'); - + it('should get metric from registry', async () => { await service.getLatestEntityMetrics('component:default/test-component', [ 'github.important_metric', ]); - expect(getMetricSpy).toHaveBeenCalled(); + expect(mockedRegistry.getMetric).toHaveBeenCalledWith( + 'github.important_metric', + ); }); it('should merge entity and provider thresholds', async () => { @@ -444,6 +453,82 @@ describe('CatalogMetricService', () => { }); }); + describe('getLatestEntityMetrics with batch providers', () => { + it('should return correct per-metric metadata for batch provider metrics', async () => { + const batchMetricsList = 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, + logger: mockedLogger, + config: mockServices.rootConfig({ data: {} }), + }); + + 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 8d7d95bedb..bfbc465230 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/CatalogMetricService.ts @@ -80,17 +80,17 @@ export class CatalogMetricService { } /** - * Get latest metric results for a specific catalog entity and metric providers. + * Get latest metric results for a specific catalog entity. * * @param entityRef - Entity reference in format "kind:namespace/name" - * @param providerIds - Optional array of provider IDs to get latest metrics of. - * If not provided, gets all available latest metrics. + * @param metricIds - Optional array of metric IDs to get latest metrics of. + * If not provided, gets all available latest metrics. * @param filter - Permission filter * @returns Metric results with entity-specific thresholds applied */ async getLatestEntityMetrics( entityRef: string, - providerIds?: string[], + metricIds?: string[], filter?: PermissionCriteria< PermissionCondition >, @@ -102,7 +102,7 @@ export class CatalogMetricService { throw new NotFoundError(`Entity not found: ${entityRef}`); } - const metricsToFetch = this.registry.listMetrics(providerIds); + const metricsToFetch = this.registry.listMetrics(metricIds); const authorizedMetricsToFetch = filterAuthorizedMetrics( metricsToFetch, @@ -119,7 +119,7 @@ export class CatalogMetricService { let thresholdError: string | undefined; const provider = this.registry.getProvider(metric_id); - const metric = provider.getMetric(); + const metric = this.registry.getMetric(metric_id); try { thresholds = mergeEntityAndProviderThresholds(entity, provider); diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts index 624972536c..6725f5df00 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.test.ts @@ -27,6 +27,7 @@ import { MetricProvidersRegistry } from '../providers/MetricProvidersRegistry'; import { MockNumberProvider, MockBooleanProvider, + MockBatchBooleanProvider, githubNumberMetricMetadata, } from '../../__fixtures__/mockProviders'; import { @@ -639,7 +640,9 @@ describe('createRouter', () => { expect(result.statusCode).toBe(404); expect(result.body.error.name).toBe('NotFoundError'); - expect(result.body.error.message).toContain('Metric provider with ID'); + expect(result.body.error.message).toContain( + 'No metric provider registered', + ); }); it('should return aggregated metrics for a specific metric', async () => { @@ -779,6 +782,44 @@ describe('createRouter', () => { mockAggregatedMetric, ); }); + + it('should use registry.getMetric to resolve the correct metric for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + '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 /aggregations/:aggregationId', () => { @@ -944,6 +985,41 @@ describe('createRouter', () => { expect(response.body.error.name).toBe('AuthenticationError'); }); + it('should resolve the correct metric for batch providers', async () => { + const batchProvider = new MockBatchBooleanProvider( + '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, @@ -1061,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 669abc8be0..ce8065f3fe 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/service/router.ts @@ -152,7 +152,7 @@ export async function createRouter({ ); const provider = metricProvidersRegistry.getProvider(metricId); - const metric = provider.getMetric(); + const metric = metricProvidersRegistry.getMetric(metricId); const authorizedMetrics = filterAuthorizedMetrics([metric], conditions); if (authorizedMetrics.length === 0) { @@ -282,7 +282,9 @@ export async function createRouter({ const provider = metricProvidersRegistry.getProvider( aggregationConfig?.metricId ?? aggregationId, ); - const metric = provider.getMetric(); + const metric = metricProvidersRegistry.getMetric( + aggregationConfig?.metricId ?? aggregationId, + ); const entitiesOwnedByAUser = await getEntitiesOwnedByUser(userEntityRef, { catalog, @@ -329,10 +331,9 @@ export async function createRouter({ aggregationId, ]); - const provider = metricProvidersRegistry.getProvider( + const metric = metricProvidersRegistry.getMetric( aggregationConfig?.metricId ?? aggregationId, ); - const metric = provider.getMetric(); res.json( AggregatedMetricMapper.toAggregationMetadata(metric, aggregationConfig), diff --git a/workspaces/scorecard/plugins/scorecard-node/report.api.md b/workspaces/scorecard/plugins/scorecard-node/report.api.md index 6fb73a54bd..f592df3706 100644 --- a/workspaces/scorecard/plugins/scorecard-node/report.api.md +++ b/workspaces/scorecard/plugins/scorecard-node/report.api.md @@ -32,8 +32,11 @@ export function getThresholdsFromConfig( // @public export interface MetricProvider { calculateMetric(entity: Entity): Promise>; + calculateMetrics?(entity: Entity): Promise>>; getCatalogFilter(): Record; getMetric(): Metric; + getMetricIds?(): string[]; + getMetrics?(): Metric[]; getMetricThresholds(): ThresholdConfig; getMetricType(): T; getProviderDatasourceId(): string; diff --git a/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts b/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts index d572304bc7..9d9385628e 100644 --- a/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts +++ b/workspaces/scorecard/plugins/scorecard-node/src/api/MetricProvider.ts @@ -62,4 +62,28 @@ export interface MetricProvider { * @public */ getCatalogFilter(): Record; + + /** + * Get all metric IDs this provider handles. + * For batch providers that handle multiple metrics. + * Defaults to [getProviderId()] if not implemented. + * @public + */ + getMetricIds?(): string[]; + + /** + * Get all metrics this provider exposes. + * For batch providers that handle multiple metrics. + * Defaults to [getMetric()] if not implemented. + * @public + */ + getMetrics?(): Metric[]; + + /** + * Calculate multiple metrics in a single call. + * For batch providers that can efficiently compute multiple metrics together. + * Defaults to [calculateMetric()] ff not implemented. + * @public + */ + calculateMetrics?(entity: Entity): Promise>>; } diff --git a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md index ba2019a066..7252216b2f 100644 --- a/workspaces/scorecard/plugins/scorecard/report-alpha.api.md +++ b/workspaces/scorecard/plugins/scorecard/report-alpha.api.md @@ -169,12 +169,16 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.github.files_check.title': string; + readonly 'metric.github.files_check.description': string; readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.exist': string; + readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; diff --git a/workspaces/scorecard/plugins/scorecard/report.api.md b/workspaces/scorecard/plugins/scorecard/report.api.md index 4c0d093e0b..6c44b7a072 100644 --- a/workspaces/scorecard/plugins/scorecard/report.api.md +++ b/workspaces/scorecard/plugins/scorecard/report.api.md @@ -69,12 +69,16 @@ export const scorecardTranslationRef: TranslationRef< readonly 'metric.github.open_prs.description': string; readonly 'metric.jira.open_issues.title': string; readonly 'metric.jira.open_issues.description': string; + readonly 'metric.github.files_check.title': string; + readonly 'metric.github.files_check.description': string; readonly 'metric.lastUpdated': string; readonly 'metric.lastUpdatedNotAvailable': string; readonly 'metric.someEntitiesNotReportingValues': string; readonly 'thresholds.success': string; readonly 'thresholds.warning': string; readonly 'thresholds.error': string; + readonly 'thresholds.exist': string; + readonly 'thresholds.missing': string; readonly 'thresholds.noEntities': string; readonly 'thresholds.entities_one': string; readonly 'thresholds.entities_other': string; diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx index 27a62c0291..4e4a631b96 100644 --- a/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/alpha/extensions/homePageCards.tsx @@ -47,6 +47,18 @@ function AggregatedCardWithGithubOpenPrsContent() { return ; } +function AggregatedCardWithGithubFilesCheckLicenseContent() { + return ( + + ); +} + +function AggregatedCardWithGithubFilesCheckCodeownersContent() { + return ( + + ); +} + function BorderlessHomeWidgetRenderer({ Content }: RendererProps) { return ; } @@ -134,3 +146,45 @@ export const aggregatedCardWithGithubOpenPrsWidget = }), }, }); + +/** + * NFS widget: AggregatedCardWithGithubFilesCheckLicense. + * @alpha + */ +export const aggregatedCardWithGithubFilesCheckLicenseWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-files-check-license', + params: { + name: 'AggregatedCardWithGithubFilesCheckLicense', + title: 'Scorecard: LICENSE file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilesCheckLicenseContent, + }), + }, + }); + +/** + * NFS widget: AggregatedCardWithGithubFilesCheckCodeowners. + * @alpha + */ +export const aggregatedCardWithGithubFilesCheckCodeownersWidget = + HomePageWidgetBlueprint.make({ + name: 'scorecard-github-files-check-codeowners', + params: { + name: 'AggregatedCardWithGithubFilesCheckCodeowners', + title: 'Scorecard: CODEOWNERS file exists', + layout: defaultCardLayout, + componentProps: { + Renderer: BorderlessHomeWidgetRenderer, + }, + components: () => + Promise.resolve({ + Content: AggregatedCardWithGithubFilesCheckCodeownersContent, + }), + }, + }); diff --git a/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx b/workspaces/scorecard/plugins/scorecard/src/alpha/index.tsx index cdda2a9fc8..db9250656e 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, + aggregatedCardWithGithubFilesCheckLicenseWidget, + aggregatedCardWithGithubFilesCheckCodeownersWidget, } from './extensions/homePageCards'; import { scorecardPage } from './extensions/scorecardPage'; @@ -82,6 +84,8 @@ export const scorecardHomeModule = createFrontendModule({ aggregatedCardWithDefaultAggregationWidget, aggregatedCardWithJiraOpenIssuesWidget, aggregatedCardWithGithubOpenPrsWidget, + aggregatedCardWithGithubFilesCheckLicenseWidget, + aggregatedCardWithGithubFilesCheckCodeownersWidget, ], }); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx index 7435ac1119..8319b94127 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/CustomLegend.tsx @@ -99,8 +99,10 @@ const CustomLegend = (props: CustomLegendProps) => { return translated === `thresholds.${ruleKey}` ? ruleKey.charAt(0).toUpperCase() + ruleKey.slice(1) : translated; - })()}{' '} - {ruleExpression && `${ruleExpression}`} + })()} + {ruleExpression && + !/^==(?:true|false)$/.test(ruleExpression) && + ` ${ruleExpression}`} ); diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx index 18753a1f32..5cbe486b15 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/EntityScorecardContent.tsx @@ -22,7 +22,7 @@ import Box from '@mui/material/Box'; import NoScorecardsState from '../Common/NoScorecardsState'; import Scorecard from './Scorecard'; import { useScorecards } from '../../hooks/useScorecards'; -import { getStatusConfig } from '../../utils'; +import { getStatusConfig, resolveMetricTranslation } from '../../utils'; import PermissionRequiredState from '../Common/PermissionRequiredState'; import { useTranslation } from '../../hooks/useTranslation'; import { CardLoading } from '../Common/CardLoading'; @@ -69,28 +69,28 @@ export const EntityScorecardContent = () => { thresholdRules: metric.result.thresholdResult.definition?.rules, }); - // Use metric ID to construct translation keys, fallback to original title/description - const titleKey = `metric.${metric.id}.title`; - const descriptionKey = `metric.${metric.id}.description`; - - const title = t(titleKey as any, {}); - const description = t(descriptionKey as any, {}); - - // If translation returns the key itself, fallback to original title/description - const finalTitle = title === titleKey ? metric.metadata.title : title; - const finalDescription = - description === descriptionKey - ? metric.metadata.description - : description; + const title = resolveMetricTranslation( + t, + metric.id, + 'title', + metric.metadata.title, + ); + const description = resolveMetricTranslation( + t, + metric.id, + 'description', + metric.metadata.description, + ); return ( - + - {!isErrorState && ( + {hasDisplayableValue && ( ({ jest.mock('../../../utils', () => ({ getStatusConfig: jest.fn(), + resolveMetricTranslation: jest.fn( + (_t: any, _metricId: string, _field: string, fallback?: string) => + fallback ?? `metric.${_metricId}.${_field}`, + ), })); const useScorecardsMock = useScorecards as jest.Mock; diff --git a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx index 891f5e5ab0..cd92e9f741 100644 --- a/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/components/Scorecard/__tests__/Scorecard.test.tsx @@ -88,6 +88,7 @@ describe('Scorecard Component', () => { statusColor: 'success.main', statusIcon: 'scorecardSuccessStatusIcon', value: 8, + metricType: 'number' as const, thresholds: { status: 'success' as const, definition: { diff --git a/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx b/workspaces/scorecard/plugins/scorecard/src/hooks/__tests__/useMetricDisplayLabels.test.tsx index bc8b7856f6..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, + ), }; }; diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts index 23724ec43d..c9391b3300 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/de.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/de.ts @@ -79,6 +79,9 @@ const scorecardTranslationDe = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira offene blockierende Tickets', 'metric.jira.open_issues.description': 'Hervorhebt die Anzahl der kritischen, blockierenden Probleme, die derzeit in Jira offen sind.', + 'metric.github.files_check.title': 'GitHub-Dateiprüfung: {{name}}', + 'metric.github.files_check.description': + 'Prüft, ob die Datei {{name}} im Repository vorhanden ist.', 'metric.lastUpdated': 'Zuletzt aktualisiert: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Zuletzt aktualisiert: Nicht verfügbar', 'metric.someEntitiesNotReportingValues': @@ -88,6 +91,8 @@ const scorecardTranslationDe = createTranslationMessages({ 'thresholds.success': 'Erfolg', 'thresholds.warning': 'Warnung', 'thresholds.error': 'Fehler', + 'thresholds.exist': 'Vorhanden', + 'thresholds.missing': 'Fehlend', 'thresholds.noEntities': 'Keine Elemente im {{category}}-Zustand', 'thresholds.entities_one': '{{count}} Element', 'thresholds.entities_other': '{{count}} Elemente', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts index e03d8e7c93..aa9e4eb220 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/es.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/es.ts @@ -80,6 +80,10 @@ const scorecardTranslationEs = createTranslationMessages({ 'metric.jira.open_issues.title': 'Jira tickets bloqueantes abiertos', 'metric.jira.open_issues.description': 'Destaca el número de problemas críticos y bloqueantes que están actualmente abiertos en Jira.', + 'metric.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.', 'metric.lastUpdated': 'Última actualización: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Última actualización: No disponible', 'metric.someEntitiesNotReportingValues': @@ -89,6 +93,8 @@ const scorecardTranslationEs = createTranslationMessages({ 'thresholds.success': 'Éxito', 'thresholds.warning': 'Advertencia', 'thresholds.error': 'Error', + 'thresholds.exist': 'Existe', + 'thresholds.missing': 'Faltante', 'thresholds.noEntities': 'No hay entidades en el estado {{category}}', 'thresholds.entities_one': '{{count}} entidad', 'thresholds.entities_other': '{{count}} entidades', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts index c931394391..086a69219e 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/fr.ts @@ -81,6 +81,10 @@ 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.', 'metric.lastUpdated': 'Dernière mise à jour: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Dernière mise à jour: Non disponible', 'metric.someEntitiesNotReportingValues': @@ -90,6 +94,8 @@ const scorecardTranslationFr = createTranslationMessages({ 'thresholds.success': 'Succès', 'thresholds.warning': 'Attention', 'thresholds.error': 'Erreur', + 'thresholds.exist': 'Existant', + 'thresholds.missing': 'Manquant', 'thresholds.noEntities': "Aucune entité dans l'état {{category}}", 'thresholds.entities_one': '{{count}} entité', 'thresholds.entities_other': '{{count}} entités', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts index 947d047a59..7ffc18d178 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/it.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/it.ts @@ -81,6 +81,9 @@ const scorecardTranslationIt = createTranslationMessages({ 'metric.jira.open_issues.title': 'Ticket di blocco Jira aperti', 'metric.jira.open_issues.description': 'Evidenzia il numero di problemi critici e di blocco attualmente aperti in Jira.', + 'metric.github.files_check.title': 'Verifica file GitHub: {{name}}', + 'metric.github.files_check.description': + 'Verifica se il file {{name}} esiste nel repository.', 'metric.lastUpdated': 'Ultimo aggiornamento: {{timestamp}}', 'metric.lastUpdatedNotAvailable': 'Ultimo aggiornamento: Non disponibile', 'metric.someEntitiesNotReportingValues': @@ -90,6 +93,8 @@ const scorecardTranslationIt = createTranslationMessages({ 'thresholds.success': 'Attività riuscita', 'thresholds.warning': 'Avviso', 'thresholds.error': 'Errore', + 'thresholds.exist': 'Esistente', + 'thresholds.missing': 'Mancante', 'thresholds.noEntities': 'Nessuna entità con stato {{category}}', 'thresholds.entities_one': '{{count}} entità', 'thresholds.entities_other': '{{count}} entità', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts index fa414ce5cf..ae4bdfa9cb 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ja.ts @@ -81,6 +81,9 @@ const scorecardTranslationJa = createTranslationMessages({ 'Jira のオープン状態の進行を妨げているチケット', 'metric.jira.open_issues.description': 'Jira で現在オープン状態になっている、重大かつ進行を妨げている課題の数を明示します。', + 'metric.github.files_check.title': 'GitHub ファイル確認: {{name}}', + 'metric.github.files_check.description': + 'リポジトリーに {{name}} ファイルが存在するかを確認します。', 'metric.lastUpdated': '最終更新日: {{timestamp}}', 'metric.lastUpdatedNotAvailable': '最終更新日: 利用不可', 'metric.someEntitiesNotReportingValues': @@ -90,6 +93,8 @@ const scorecardTranslationJa = createTranslationMessages({ 'thresholds.success': '成功', 'thresholds.warning': '警告', 'thresholds.error': 'エラー', + 'thresholds.exist': '存在', + 'thresholds.missing': '欠落', 'thresholds.noEntities': '{{category}} 状態のエンティティーがありません', 'thresholds.entities_one': '{{count}} エンティティー', 'thresholds.entities_other': '{{count}} エンティティー', diff --git a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts index e31ab776f3..ea2fa576c0 100644 --- a/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts +++ b/workspaces/scorecard/plugins/scorecard/src/translations/ref.ts @@ -87,6 +87,10 @@ export const scorecardMessages = { description: 'Highlights the number of critical, blocking issues that are currently open in Jira.', }, + 'github.files_check': { + title: 'GitHub file check: {{name}}', + description: 'Checks whether the {{name}} file exists in the repository.', + }, lastUpdated: 'Last updated: {{timestamp}}', lastUpdatedNotAvailable: 'Last updated: Not available', someEntitiesNotReportingValues: @@ -98,6 +102,8 @@ export const scorecardMessages = { success: 'Success', warning: 'Warning', error: 'Error', + exist: 'Exist', + missing: 'Missing', noEntities: 'No entities in {{category}} state', entities_one: '{{count}} entity', entities_other: '{{count}} entities', diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx index 49c083d5e5..2c8736fe21 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx +++ b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/statusUtils.test.tsx @@ -15,7 +15,11 @@ */ import type { Theme } from '@mui/material/styles'; -import { DEFAULT_NUMBER_THRESHOLDS } from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; + +import { + DEFAULT_NUMBER_THRESHOLDS, + ScorecardThresholdRuleColors, +} from '@red-hat-developer-hub/backstage-plugin-scorecard-common'; import { getStatusConfig, resolveStatusColor, @@ -170,6 +174,60 @@ describe('statusUtils', () => { icon: 'scorecardSuccessStatusIcon', }); }); + + it('should return icon for missing evaluation with boolean threshold rules', () => { + const result = getStatusConfig({ + evaluation: 'missing', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }); + + expect(result).toEqual({ + color: 'error.main', + icon: 'scorecardErrorStatusIcon', + }); + }); + + it('should return icon for exist evaluation with boolean threshold rules', () => { + const result = getStatusConfig({ + evaluation: 'exist', + thresholdStatus: 'success', + metricStatus: 'success', + thresholdRules: [ + { + key: 'exist', + expression: '==true', + color: ScorecardThresholdRuleColors.SUCCESS, + icon: 'scorecardSuccessStatusIcon', + }, + { + key: 'missing', + expression: '==false', + color: ScorecardThresholdRuleColors.ERROR, + icon: 'scorecardErrorStatusIcon', + }, + ], + }); + + expect(result).toEqual({ + color: 'success.main', + icon: 'scorecardSuccessStatusIcon', + }); + }); }); describe('optional parameters', () => { diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts b/workspaces/scorecard/plugins/scorecard/src/utils/__tests__/translationUtils.test.ts new file mode 100644 index 0000000000..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 50f6c6c6e7..84ba13a5f1 100644 --- a/workspaces/scorecard/plugins/scorecard/src/utils/index.ts +++ b/workspaces/scorecard/plugins/scorecard/src/utils/index.ts @@ -25,3 +25,4 @@ export { export { getLastUpdatedLabel } from './entityTableUtils'; export { getStatusConfig, resolveStatusColor } from './statusUtils'; export { getThresholdRuleColor, getThresholdRuleIcon } from './thresholdUtils'; +export { resolveMetricTranslation } from './translationUtils'; diff --git a/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts b/workspaces/scorecard/plugins/scorecard/src/utils/translationUtils.ts new file mode 100644 index 0000000000..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; +}