Skip to content
16 changes: 16 additions & 0 deletions workspaces/scorecard/.changeset/tired-hoops-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dependabot': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-filecheck': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-sonarqube': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-openssf': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-node': patch
---

Custom thresholds for filecheck, openssf, and dependabot are now
configurable. Custom threshold handling has been centralized in
`scorecard-backend`, you can define custom thresholds under
`scorecard.plugins.<providerId>.thresholds`. Provider IDs typically
follow the format `<datasource>.<metric>`.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { ConfigReader } from '@backstage/config';
import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';
import { DependabotMetricProvider } from './DependabotMetricProvider';
import { DEPENDABOT_SEVERITY_METRIC } from './DependabotConfig';
import {
DEPENDABOT_SEVERITY_METRIC,
DEPENDABOT_THRESHOLDS,
} from './DependabotConfig';
import { mockServices } from '@backstage/backend-test-utils';

jest.mock('@backstage/catalog-model', () => ({
Expand Down Expand Up @@ -105,25 +108,13 @@ describe('DependabotMetricProvider', () => {
});

describe('getMetricThresholds', () => {
it('returns default thresholds when none provided', () => {
it('returns default thresholds', () => {
const provider = new DependabotMetricProvider(
mockConfig,
mockLogger,
'critical',
);
expect(provider.getMetricThresholds()).toBeDefined();
expect(provider.getMetricThresholds().rules).toBeDefined();
});

it('returns custom thresholds when provided', () => {
const custom = { rules: [{ key: 'ok', expression: '<1' }] };
const provider = new DependabotMetricProvider(
mockConfig,
mockLogger,
'critical',
custom,
);
expect(provider.getMetricThresholds()).toEqual(custom);
expect(provider.getMetricThresholds()).toEqual(DEPENDABOT_THRESHOLDS);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,15 @@ const GITHUB_PROJECT_ANNOTATION = 'github.com/project-slug';
*/
export class DependabotMetricProvider implements MetricProvider<'number'> {
private readonly dependabotClient: DependabotClient;
private readonly thresholds: ThresholdConfig;
private readonly severity: DependabotSeverity;

constructor(
config: Config,
logger: LoggerService,
severity: DependabotSeverity,
thresholds?: ThresholdConfig,
) {
this.severity = severity;
this.dependabotClient = new DependabotClient(config, logger);
this.thresholds = thresholds ?? DEPENDABOT_THRESHOLDS;
}

getProviderDatasourceId(): string {
Expand All @@ -82,7 +79,7 @@ export class DependabotMetricProvider implements MetricProvider<'number'> {
}

getMetricThresholds(): ThresholdConfig {
return this.thresholds;
return DEPENDABOT_THRESHOLDS;
}

getCatalogFilter(): Record<string, string | symbol | (string | symbol)[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
createDependabotMetricProviders,
} from './DependabotMetricProviderFactory';
import { mockServices } from '@backstage/backend-test-utils';
import { DEPENDABOT_THRESHOLDS } from './DependabotConfig';

const mockConfig = new ConfigReader({
integrations: { github: [{ host: 'github.com', token: 'test-token' }] },
Expand All @@ -36,17 +37,7 @@ describe('createDependabotMetricProvider', () => {
expect(provider.getProviderId()).toBe('dependabot.alerts_high');
expect(provider.getProviderDatasourceId()).toBe('dependabot');
expect(provider.getMetricType()).toBe('number');
});

it('accepts optional thresholds', () => {
const thresholds = { rules: [{ key: 'ok', expression: '<1' }] };
const provider = createDependabotMetricProvider(
mockConfig,
mockLogger,
'critical',
thresholds,
);
expect(provider.getMetricThresholds()).toEqual(thresholds);
expect(provider.getMetricThresholds()).toBe(DEPENDABOT_THRESHOLDS);
});
});

Expand All @@ -61,16 +52,4 @@ describe('createDependabotMetricProviders', () => {
'dependabot.alerts_low',
]);
});

it('passes optional thresholds to all providers', () => {
const thresholds = { rules: [{ key: 'custom', expression: '>0' }] };
const providers = createDependabotMetricProviders(
mockConfig,
mockLogger,
thresholds,
);
providers.forEach(p => {
expect(p.getMetricThresholds()).toEqual(thresholds);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import { DependabotMetricProvider } from './DependabotMetricProvider';
import { DependabotSeverity, DEPENDABOT_SEVERITIES } from './DependabotConfig';
import { Config } from '@backstage/config';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ThresholdConfig } from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';

/**
Expand All @@ -27,9 +26,8 @@ export function createDependabotMetricProvider(
config: Config,
logger: LoggerService,
severity: DependabotSeverity,
thresholds?: ThresholdConfig,
): MetricProvider<'number'> {
return new DependabotMetricProvider(config, logger, severity, thresholds);
return new DependabotMetricProvider(config, logger, severity);
}

/**
Expand All @@ -38,9 +36,8 @@ export function createDependabotMetricProvider(
export function createDependabotMetricProviders(
config: Config,
logger: LoggerService,
thresholds?: ThresholdConfig,
): MetricProvider<'number'>[] {
return DEPENDABOT_SEVERITIES.map(severity =>
createDependabotMetricProvider(config, logger, severity, thresholds),
createDependabotMetricProvider(config, logger, severity),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,25 @@ Each configured file produces one boolean metric.

You can override the default thresholds via `app-config.yaml`. Check out the detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md).

Example configuration:

```yaml
# app-config.yaml
scorecard:
plugins:
filecheck:
thresholds:
rules:
- key: present
expression: '==true'
icon: scorecardSuccessStatusIcon
color: 'success.main'
- key: absent
expression: '==false'
icon: scorecardErrorStatusIcon
color: 'error.main'
```

## Schedule Configuration

The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. You can change this schedule in the `app-config.yaml` file:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,10 @@ import {
export class FilecheckMetricProvider implements MetricProvider<'boolean'> {
private readonly client: FilecheckClient;
private readonly filesConfig: FilecheckConfig;
private readonly thresholds: ThresholdConfig;

constructor(
client: FilecheckClient,
filesConfig: FilecheckConfig,
thresholds?: ThresholdConfig,
) {
constructor(client: FilecheckClient, filesConfig: FilecheckConfig) {
this.client = client;
this.filesConfig = filesConfig;
this.thresholds = thresholds ?? DEFAULT_FILECHECK_THRESHOLDS;
}

getProviderDatasourceId(): string {
Expand Down Expand Up @@ -73,7 +67,7 @@ export class FilecheckMetricProvider implements MetricProvider<'boolean'> {
}

getMetricThresholds(): ThresholdConfig {
return this.thresholds;
return DEFAULT_FILECHECK_THRESHOLDS;
}

getCatalogFilter(): Record<string, string | symbol | (string | symbol)[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,56 +31,11 @@ jest.mock('../github/GithubClient');

describe('GithubOpenPRsProvider', () => {
describe('fromConfig', () => {
it('should create provider with default thresholds when no thresholds are configured', () => {
it('should create provider with default thresholds', () => {
const provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({}));

expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS);
});

it('should create provider with custom thresholds when configured', () => {
const customThresholds = {
rules: [
{ key: 'error', expression: '>100' },
{ key: 'warning', expression: '50-100' },
{ key: 'success', expression: '<50' },
],
};

const configWithThresholds = new ConfigReader({
scorecard: {
plugins: {
github: {
open_prs: {
thresholds: customThresholds,
},
},
},
},
});
const provider = GithubOpenPRsProvider.fromConfig(configWithThresholds);

expect(provider.getMetricThresholds()).toEqual(customThresholds);
});

it('should throw error when invalid custom thresholds', () => {
const invalidConfig = new ConfigReader({
scorecard: {
plugins: {
github: {
open_prs: {
thresholds: {
rules: [{ key: 'error', expression: '>!100' }],
},
},
},
},
},
});

expect(() => GithubOpenPRsProvider.fromConfig(invalidConfig)).toThrow(
'Cannot parse "!100" as number from expression: ">!100"',
);
});
});

describe('calculateMetric', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,15 @@ import {
Metric,
ThresholdConfig,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
import {
getThresholdsFromConfig,
MetricProvider,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { GithubClient } from '../github/GithubClient';
import { getRepositoryInformationFromEntity } from '../github/utils';

export class GithubOpenPRsProvider implements MetricProvider<'number'> {
private readonly githubClient: GithubClient;
private readonly thresholds: ThresholdConfig;

private constructor(config: Config, thresholds?: ThresholdConfig) {
private constructor(config: Config) {
this.githubClient = new GithubClient(config);
this.thresholds = thresholds ?? DEFAULT_NUMBER_THRESHOLDS;
}

getProviderDatasourceId(): string {
Expand All @@ -62,7 +57,7 @@ export class GithubOpenPRsProvider implements MetricProvider<'number'> {
}

getMetricThresholds(): ThresholdConfig {
return this.thresholds;
return DEFAULT_NUMBER_THRESHOLDS;
}

getCatalogFilter(): Record<string, string | symbol | (string | symbol)[]> {
Expand All @@ -72,13 +67,7 @@ export class GithubOpenPRsProvider implements MetricProvider<'number'> {
}

static fromConfig(config: Config): GithubOpenPRsProvider {
const thresholds = getThresholdsFromConfig(
config,
'scorecard.plugins.github.open_prs.thresholds',
'number',
);

return new GithubOpenPRsProvider(config, thresholds);
return new GithubOpenPRsProvider(config);
}

async calculateMetric(entity: Entity): Promise<number> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,13 @@ import type { Entity } from '@backstage/catalog-model';
import {
DEFAULT_NUMBER_THRESHOLDS,
Metric,
ThresholdConfig,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
import { JiraOpenIssuesProvider } from './JiraOpenIssuesProvider';
import { JiraClientFactory } from '../clients/JiraClientFactory';
import { JiraClient } from '../clients/base';
import { mockServices } from '@backstage/backend-test-utils';
import {
newEntityComponent,
newThresholdsConfig,
newMockRootConfig,
} from '../../__fixtures__/testUtils';
import { ScorecardJiraAnnotations } from '../annotations';
Expand Down Expand Up @@ -61,8 +59,6 @@ const mockEntity: Entity = newEntityComponent({
[PROJECT_KEY]: 'TEST',
});

const customThresholds: ThresholdConfig = newThresholdsConfig();

const mockAuthOptions = {
discovery: mockServices.discovery(),
auth: mockServices.auth(),
Expand Down Expand Up @@ -142,23 +138,13 @@ describe('JiraOpenIssuesProvider', () => {
});

describe('getMetricThresholds', () => {
it('should return default config when no thresholds are configured', () => {
it('should return default provider thresholds', () => {
const provider = JiraOpenIssuesProvider.fromConfig(
mockConfig,
mockAuthOptions,
);
expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS);
});

it('should return custom config when thresholds are configured', () => {
const config = newMockRootConfig({ thresholds: customThresholds });

const provider = JiraOpenIssuesProvider.fromConfig(
config,
mockAuthOptions,
);
expect(provider.getMetricThresholds()).toEqual(customThresholds);
});
});

describe('supportsEntity', () => {
Expand Down Expand Up @@ -191,27 +177,6 @@ describe('JiraOpenIssuesProvider', () => {
expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS);
});

it('should create provider with custom config when thresholds are configured', () => {
const config = newMockRootConfig({ thresholds: customThresholds });

const provider = JiraOpenIssuesProvider.fromConfig(
config,
mockAuthOptions,
);
expect(provider.getMetricThresholds()).toEqual(customThresholds);
});

it('should throw an error when invalid thresholds are configured', () => {
const invalidThresholds = {
rules: [{ key: 'invalid', expression: 'bad' }],
};
const config = newMockRootConfig({ thresholds: invalidThresholds });

expect(() =>
JiraOpenIssuesProvider.fromConfig(config, mockAuthOptions),
).toThrow('Invalid thresholds');
});

it('should create provider with proxy connection strategy when proxy path is configured', () => {
JiraOpenIssuesProvider.fromConfig(mockConfig, mockAuthOptions);
expect(mockedProxyConnectionStrategy).toHaveBeenCalledWith(
Expand Down
Loading
Loading