Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions workspaces/scorecard/examples/all-scorecards.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,18 @@ spec:
type: service
owner: user:development/guest
lifecycle: production
---
# Component with both GitHub and Jira Scorecards with not specified owner
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: all-scorecards-service-different-owner
annotations:
github.com/project-slug: redhat-developer/rhdh-plugins
backstage.io/source-location: url:https://github.com/redhat-developer/rhdh-plugins
jira/project-key: RSPT
jira/label: JupiterTeam
spec:
type: service
owner: rhdh-team
lifecycle: production
5 changes: 2 additions & 3 deletions workspaces/scorecard/plugins/scorecard-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,9 @@ The Scorecard plugin provides aggregation endpoints that return metrics for all
- Entities directly owned by the user
- Entities owned by groups the user is a direct member of (Only direct parent groups are considered)

### Available Endpoints
### Available Endpoint

- **`GET /metrics/catalog/aggregates`**: Returns aggregated metrics for all available metrics (optionally filtered by `metricIds` query parameter)
- **`GET /metrics/:metricId/catalog/aggregation`**: Returns aggregated metrics for a specific metric, with explicit access validation (returns `403` if the user doesn't have access to the metric)
- **`GET /metrics/:metricId/catalog/aggregations`**: Returns aggregated metrics for a specific metric across all entities owned by the authenticated user, with explicit access validation (returns `403` if the user doesn't have access to the metric)

For comprehensive documentation on how entity aggregation works, API details, examples, and best practices, see [aggregation.md](./docs/aggregation.md).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const mockMetricProvidersRegistry = {
calculateMetrics: jest.fn(),
listProviders: jest.fn().mockReturnValue([]),
listMetrics: jest.fn().mockReturnValue([]),
listMetricsByDatasource: jest.fn().mockReturnValue([]),
} as unknown as jest.Mocked<MetricProvidersRegistry>;

type BuildMockMetricProvidersRegistryParams = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Entity Aggregation

The Scorecard plugin provides aggregation endpoints that return metrics aggregated across all entities owned by the authenticated user. This feature allows users to get a consolidated view of metrics across their entire portfolio of owned entities.
The Scorecard plugin provides an aggregation endpoint that returns metrics aggregated across all entities owned by the authenticated user. This feature allows users to get a consolidated view of metrics across their entire portfolio of owned entities.

## Overview

The aggregation endpoints (`/metrics/catalog/aggregates` and `/metrics/:metricId/catalog/aggregation`) aggregate metrics from multiple entities based on entity ownership. They collect metrics from:
The aggregation endpoint (`/metrics/:metricId/catalog/aggregations`) aggregates metrics from multiple entities based on entity ownership. It collects metrics from:

- Entities directly owned by the user
- Entities owned by groups the user is a direct member of
Expand All @@ -28,39 +28,9 @@ In this case:
- ✅ Entities owned by `group:default/developers` are included
- ❌ Entities owned by `group:default/engineering` are **NOT** included

## API Endpoints
## API Endpoint

### `GET /metrics/catalog/aggregates`

Returns aggregated metrics for all entities owned by the authenticated user.

#### Query Parameters

| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | -------------------------------------------------------------------------------------------- |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter. If not provided, returns all available metrics |

#### Authentication

Requires user authentication. The endpoint uses the authenticated user's entity reference to determine which entities to aggregate.

#### Permissions

Requires `scorecard.metric.read` permission. Additionally, the user must have `catalog.entity.read` permission for each entity that will be included in the aggregation.

#### Example Request

```bash
# Get all aggregated metrics
curl -X GET "{{url}}/api/scorecard/metrics/catalog/aggregates" \
-H "Authorization: Bearer <token>"

# Get specific metrics
curl -X GET "{{url}}/api/scorecard/metrics/catalog/aggregates?metricIds=github.open_prs,jira.open_issues" \
-H "Authorization: Bearer <token>"
```

### `GET /metrics/:metricId/catalog/aggregation`
### `GET /metrics/:metricId/catalog/aggregations`

Returns aggregated metrics for a specific metric across all entities owned by the authenticated user. This endpoint is useful when you need to check access to a specific metric and get its aggregation without requiring the `metricIds` query parameter.

Expand All @@ -85,11 +55,11 @@ Requires `scorecard.metric.read` permission. Additionally:

```bash
# Get aggregated metrics for a specific metric
curl -X GET "{{url}}/api/scorecard/metrics/github.open_prs/catalog/aggregation" \
curl -X GET "{{url}}/api/scorecard/metrics/github.open_prs/catalog/aggregations" \
-H "Authorization: Bearer <token>"
```

#### Differences from `/metrics/catalog/aggregates`
#### Key Features

- **Metric Access Validation**: This endpoint explicitly validates that the user has access to the specified metric and returns `403 Forbidden` if access is denied
- **Single Metric Only**: Returns aggregation for only the specified metric (no need for `metricIds` query parameter)
Expand All @@ -101,8 +71,8 @@ curl -X GET "{{url}}/api/scorecard/metrics/github.open_prs/catalog/aggregation"

If the authenticated user doesn't have an entity reference in the catalog:

- **Status Code**: `403 Forbidden`
- **Error**: `NotAllowedError: User entity reference not found`
- **Status Code**: `404 Not Found`
- **Error**: `NotFoundError: User entity reference not found`

### Permission Denied

Expand All @@ -111,12 +81,12 @@ If the user doesn't have permission to read a specific entity:
- **Status Code**: `403 Forbidden`
- **Error**: Permission denied for the specific entity

### Metric Access Denied (for `/metrics/:metricId/catalog/aggregation`)
### Metric Access Denied (for `/metrics/:metricId/catalog/aggregations`)

If the user doesn't have access to the specified metric:

- **Status Code**: `403 Forbidden`
- **Error**: `NotAllowedError: Access to metric "<metricId>" denied`
- **Error**: `NotAllowedError: To view the scorecard metrics, your administrator must grant you the required permission.`

### Invalid Query Parameters

Expand All @@ -127,8 +97,8 @@ If invalid query parameters are provided:

## Best Practices

1. **Use Metric Filtering**: When you only need specific metrics, use the `metricIds` parameter to reduce response size and improve performance
1. **Handle Empty Results**: Always check for empty arrays when the user owns no entities

2. **Handle Empty Results**: Always check for empty arrays when the user owns no entities
2. **Group Structure**: Be aware of the direct parent group limitation when designing your group hierarchy. If you need nested group aggregation, consider restructuring your groups or implementing custom logic

3. **Group Structure**: Be aware of the direct parent group limitation when designing your group hierarchy. If you need nested group aggregation, consider restructuring your groups or implementing custom logic
3. **Metric Access**: This endpoint validates metric access upfront, so you'll get a clear `403 Forbidden` error if the user doesn't have permission to view the specified metric
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,19 @@ describe('MetricProvidersRegistry', () => {
});

describe('listMetrics', () => {
beforeEach(() => {
registry.register(githubNumberProvider);
registry.register(jiraBooleanProvider);
});

it('should return empty array when no providers registered', () => {
registry = new MetricProvidersRegistry();

const metrics = registry.listMetrics();
expect(metrics).toEqual([]);
});

it('should return all registered metrics', () => {
registry.register(githubNumberProvider);
registry.register(jiraBooleanProvider);

const metrics = registry.listMetrics();

expect(metrics).toHaveLength(2);
Expand All @@ -318,50 +322,32 @@ describe('MetricProvidersRegistry', () => {
});

it('should return filtered metrics', () => {
registry.register(githubNumberProvider);
registry.register(jiraBooleanProvider);

const metrics = registry.listMetrics(['jira.boolean_metric']);

expect(metrics).toHaveLength(1);
expect(metrics[0].id).toBe('jira.boolean_metric');
});
});

describe('listMetricsByDatasource', () => {
beforeEach(() => {
const githubProvider1 = new MockNumberProvider(
'github.open_prs',
'github',
'GitHub Open PRs',
);
const githubProvider2 = new MockNumberProvider(
'github.open_issues',
'github',
'GitHub Open Issues',
);
const sonarProvider = new MockBooleanProvider(
'sonar.code-quality',
'sonar',
'Code Quality',
);

registry.register(githubProvider1);
registry.register(githubProvider2);
registry.register(sonarProvider);
});
it('should return empty array when all provider IDs are non-existent', () => {
const metrics = registry.listMetrics([
'non.existent.metric1',
'non.existent.metric2',
]);

it('should return empty array for non_existent datasource', () => {
const metrics = registry.listMetricsByDatasource('non_existent');
expect(metrics).toEqual([]);
});

it('should return metrics for specific datasource', () => {
const githubMetrics = registry.listMetricsByDatasource('github');
it('should return only existing metrics when mix of existing and non-existent IDs', () => {
const metrics = registry.listMetrics([
'github.number_metric',
'non.existent.metric',
'jira.boolean_metric',
'another.non.existent',
]);

expect(githubMetrics).toHaveLength(2);
expect(githubMetrics[0].id).toBe('github.open_prs');
expect(githubMetrics[1].id).toBe('github.open_issues');
expect(metrics).toHaveLength(2);
expect(metrics[0].id).toBe('github.number_metric');
expect(metrics[1].id).toBe('jira.boolean_metric');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { MetricProvider } from '@red-hat-developer-hub/backstage-plugin-scorecar
*/
export class MetricProvidersRegistry {
private readonly metricProviders = new Map<string, MetricProvider>();
private readonly datasourceIndex = new Map<string, Set<string>>();

register(metricProvider: MetricProvider): void {
const providerId = metricProvider.getProviderId();
Expand Down Expand Up @@ -64,12 +63,6 @@ export class MetricProvidersRegistry {
}

this.metricProviders.set(providerId, metricProvider);
let datasourceProviders = this.datasourceIndex.get(providerDatasource);
if (!datasourceProviders) {
datasourceProviders = new Set();
this.datasourceIndex.set(providerDatasource, datasourceProviders);
}
datasourceProviders.add(providerId);
}

getProvider(providerId: string): MetricProvider {
Expand Down Expand Up @@ -124,16 +117,4 @@ export class MetricProvidersRegistry {
provider.getMetric(),
);
}

listMetricsByDatasource(datasourceId: string): Metric[] {
const providerIdsOfDatasource = this.datasourceIndex.get(datasourceId);
if (!providerIdsOfDatasource) {
return [];
}

return Array.from(providerIdsOfDatasource)
.map(providerId => this.metricProviders.get(providerId))
.filter((provider): provider is MetricProvider => provider !== undefined)
.map(provider => provider.getMetric());
}
}
Loading