Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions workspaces/scorecard/.changeset/free-rice-pick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-github': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': 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
---

Adds database persistence and scheduled metric collection.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add breaking here since we are now requiring getCatalogFilter
**BREAKING**: ...

  • Add information about what is breaking - getCatalogFilter

Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,23 @@ This metric counts all pull requests that are currently in an "open" state for t
### Threshold Configuration

Thresholds define conditions that determine which category a metric value belongs to ( `error`, `warning`, or `success`). You can configure custom thresholds for the GitHub metrics. Check out detailed explanation of [threshold configuration](../scorecard-backend/docs/thresholds.md).

## Schedule Configuration

The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. However, this configuration can be changed in the `app-config.yaml` file. Here is an example of how to do that:

```yaml
scorecard:
plugins:
github:
open_prs:
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).
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api';

export interface Config {
/** Configuration for scorecard plugin */
Expand All @@ -29,6 +30,7 @@ export interface Config {
expression: string;
}>;
};
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
};
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.4.2",
"@backstage/catalog-client": "^1.11.0",
"@backstage/catalog-model": "^1.7.5",
"@backstage/integration": "^1.17.1",
"@backstage/plugin-catalog-node": "^1.18.0",
"@octokit/graphql": "^9.0.1",
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,42 +30,6 @@ jest.mock('@backstage/catalog-model', () => ({
jest.mock('../github/GithubClient');

describe('GithubOpenPRsProvider', () => {
describe('supportsEntity', () => {
let provider: GithubOpenPRsProvider;

beforeEach(() => {
provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({}));
});

it.each([
[
'should return true for entity with github.com/project-slug annotation',
{
'github.com/project-slug': 'org/repo',
},
true,
],
[
'should return false for entity without github.com/project-slug annotation',
{
'some.other/annotation': 'value',
},
false,
],
['should return false for entity with no annotations', undefined, false],
])('%s', (_, annotations, expected) => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'test-component',
annotations,
},
};
expect(provider.supportsEntity(entity)).toBe(expected);
});
});

describe('fromConfig', () => {
it('should create provider with default thresholds when no thresholds are configured', () => {
const provider = GithubOpenPRsProvider.fromConfig(new ConfigReader({}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,22 @@

import type { Config } from '@backstage/config';
import { getEntitySourceLocation, type Entity } from '@backstage/catalog-model';
import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client';
import {
DEFAULT_NUMBER_THRESHOLDS,
Metric,
ThresholdConfig,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
import {
getThresholdsFromConfig,
MetricProvider,
validateThresholds,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { GithubClient } from '../github/GithubClient';
import { getRepositoryInformationFromEntity } from '../github/utils';
import { GITHUB_PROJECT_ANNOTATION } from '../github/constants';

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

private constructor(config: Config, thresholds?: ThresholdConfig) {
this.githubClient = new GithubClient(config);
Expand Down Expand Up @@ -61,20 +61,20 @@ export class GithubOpenPRsProvider implements MetricProvider<'number'> {
return this.thresholds;
}

supportsEntity(entity: Entity): boolean {
return (
entity.metadata.annotations?.[GITHUB_PROJECT_ANNOTATION] !== undefined
);
getCatalogFilter(): Record<string, string | symbol | (string | symbol)[]> {
return {
'metadata.annotations.github.com/project-slug': CATALOG_FILTER_EXISTS,
};
}

static fromConfig(config: Config): GithubOpenPRsProvider {
const configPath = 'scorecard.plugins.github.open_prs.thresholds';
const configuredThresholds = config.getOptional(configPath);
if (configuredThresholds !== undefined) {
validateThresholds(configuredThresholds, 'number');
}
const thresholds = getThresholdsFromConfig(
config,
'scorecard.plugins.github.open_prs.thresholds',
'number',
);

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

async calculateMetric(entity: Entity): Promise<number> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ This module also requires a Jira integration to be configured in your `app-confi

## Configuration

### Authentification `token`
### Authentication `token`

- For the `cloud` product:

Expand Down Expand Up @@ -98,6 +98,26 @@ scorecard:
customFilter: priority in ("Critical", "Blocker")
```

## Schedule Configuration

The Scorecard plugin uses Backstage's built-in scheduler service to automatically collect metrics from all registered providers every hour by default. However, this configuration can be changed in the `app-config.yaml` file. Here is an example of how to do that:

```yaml
scorecard:
plugins:
jira:
open_issues:
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).

## Installation

To install this backend module:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* limitations under the License.
*/

import { SchedulerServiceTaskScheduleDefinitionConfig } from '@backstage/backend-plugin-api';

export interface Config {
/** Configuration for jira plugin */
jira: (
Expand Down Expand Up @@ -48,6 +50,7 @@ export interface Config {
expression: string;
}>;
};
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
};
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@
},
"dependencies": {
"@backstage/backend-plugin-api": "^1.4.2",
"@backstage/catalog-client": "^1.11.0",
"@backstage/catalog-model": "^1.7.5",
"@backstage/plugin-catalog-node": "^1.18.0",
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "^1.8.0",
"@backstage/catalog-model": "^1.7.5",
"@backstage/cli": "^0.34.1",
"@backstage/config": "^1.3.3"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import type { Config } from '@backstage/config';
import type { Entity } from '@backstage/catalog-model';
import { JiraEntityFilters, JiraOptions, RequestOptions } from './types';
import { JIRA_OPTIONS_PATH, JIRA_MANDATORY_FILTER } from '../constants';
import { JIRA_MANDATORY_FILTER, OPEN_ISSUES_CONFIG_PATH } from '../constants';
import { ScorecardJiraAnnotations } from '../annotations';
import { sanitizeValue, validateIdentifier, validateJQLValue } from './utils';
import { ConnectionStrategy } from '../strategies/ConnectionStrategy';
Expand All @@ -32,7 +32,9 @@ export abstract class JiraClient {
constructor(rootConfig: Config, connectionStrategy: ConnectionStrategy) {
this.connectionStrategy = connectionStrategy;

const jiraOptions = rootConfig.getOptionalConfig(JIRA_OPTIONS_PATH);
const jiraOptions = rootConfig.getOptionalConfig(
`${OPEN_ISSUES_CONFIG_PATH}.options`,
);
if (jiraOptions) {
this.options = {
mandatoryFilter: jiraOptions.getOptionalString('mandatoryFilter'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,15 @@
* limitations under the License.
*/

/**
* Jira open issues thresholds configuration path
* @public
*/
export const THRESHOLDS_CONFIG_PATH =
'scorecard.plugins.jira.open_issues.thresholds' as const;
export const OPEN_ISSUES_CONFIG_PATH =
'scorecard.plugins.jira.open_issues' as const;

/**
* Jira integration configuration path
* @public
*/
export const JIRA_CONFIG_PATH = 'jira' as const;

/**
* Jira open issues options configuration path
* @public
*/
export const JIRA_OPTIONS_PATH =
'scorecard.plugins.jira.open_issues.options' as const;

export const DATA_CENTER_API_VERSION = 2 as const;

export const CLOUD_API_VERSION = 3 as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
Metric,
ThresholdConfig,
} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';
import { validateThresholds } from '@red-hat-developer-hub/backstage-plugin-scorecard-node';
import { JiraOpenIssuesProvider } from './JiraOpenIssuesProvider';
import { JiraClientFactory } from '../clients/JiraClientFactory';
import { JiraClient } from '../clients/base';
Expand All @@ -40,9 +39,6 @@ import {
const { PROJECT_KEY } = ScorecardJiraAnnotations;

jest.mock('../clients/JiraClientFactory');
jest.mock('@red-hat-developer-hub/backstage-plugin-scorecard-node', () => ({
validateThresholds: jest.fn(),
}));
jest.mock('../strategies/ConnectionStrategy');

const mockJiraClient = {
Expand All @@ -60,9 +56,6 @@ const mockedDirectConnectionStrategy =
DirectConnectionStrategy as unknown as jest.Mocked<
typeof DirectConnectionStrategy
>;
const mockedValidateThresholds = validateThresholds as jest.MockedFunction<
typeof validateThresholds
>;

const mockEntity: Entity = newEntityComponent({
[PROJECT_KEY]: 'TEST',
Expand Down Expand Up @@ -180,7 +173,6 @@ describe('JiraOpenIssuesProvider', () => {
mockAuthOptions,
);

expect(mockedValidateThresholds).not.toHaveBeenCalled();
expect(provider.getMetricThresholds()).toEqual(DEFAULT_NUMBER_THRESHOLDS);
});

Expand All @@ -191,30 +183,18 @@ describe('JiraOpenIssuesProvider', () => {
config,
mockAuthOptions,
);

expect(mockedValidateThresholds).toHaveBeenCalledWith(
customThresholds,
'number',
);
expect(provider.getMetricThresholds()).toEqual(customThresholds);
});

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

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

it('should create provider with proxy connection strategy when proxy path is configured', () => {
Expand Down
Loading