Skip to content
Merged
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
9 changes: 9 additions & 0 deletions workspaces/scorecard/.changeset/young-suits-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend': patch
'@red-hat-developer-hub/backstage-plugin-scorecard-common': patch
'@red-hat-developer-hub/backstage-plugin-scorecard': patch
---

Fix aggregated scorecard widgets view when entities are missing value or metric fetching fails.

Refactor the /metrics/:metricId/catalog/aggregations endpoint to return an object of aggregated metrics instead of an array containing a single object.
21 changes: 17 additions & 4 deletions workspaces/scorecard/packages/app/e2e-tests/scorecard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
invalidThresholdResponse,
githubAggregatedResponse,
jiraAggregatedResponse,
emptyGithubAggregatedResponse,
emptyJiraAggregatedResponse,
} from './utils/scorecardResponseUtils';
import {
ScorecardMessages,
Expand Down Expand Up @@ -265,13 +267,24 @@ test.describe('Scorecard Plugin Tests', () => {
await runAccessibilityTests(page, testInfo);
});

test('Verify cards hidden when API returns empty response', async () => {
await mockAggregatedScorecardResponse(page, [], []);
test('Verify cards aggregation data is not found when API returns empty aggregated response', async () => {
await mockAggregatedScorecardResponse(
page,
emptyGithubAggregatedResponse,
emptyJiraAggregatedResponse,
);

await homePage.navigateToHome();
await page.reload();
await homePage.expectCardNotVisible('github.open_prs');
await homePage.expectCardNotVisible('jira.open_issues');
await homePage.expectCardVisible('github.open_prs');
await homePage.expectCardVisible('jira.open_issues');

await expect(page.locator('article')).toContainText(
translations.errors.noDataFound,
);
await expect(page.locator('article')).toContainText(
translations.errors.noDataFoundMessage,
);
});

test('Verify threshold tooltips', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,48 +171,86 @@ export const invalidThresholdResponse = [
];

// Aggregated scorecard responses (15 GitHub entities, 10 Jira entities)
export const githubAggregatedResponse = [
{
id: 'github.open_prs',
status: 'success',
metadata: {
title: 'GitHub open PRs',
description:
'Current count of open Pull Requests for a given GitHub repository.',
type: 'number',
history: true,
},
result: {
values: [
{ count: 5, name: 'success' },
{ count: 7, name: 'warning' },
{ count: 3, name: 'error' },
],
total: 15,
timestamp: '2026-01-24T14:10:32.858Z',
},
export const githubAggregatedResponse = {
id: 'github.open_prs',
status: 'success',
metadata: {
title: 'GitHub open PRs',
description:
'Current count of open Pull Requests for a given GitHub repository.',
type: 'number',
history: true,
},
];
result: {
values: [
{ count: 5, name: 'success' },
{ count: 7, name: 'warning' },
{ count: 3, name: 'error' },
],
total: 15,
timestamp: '2026-01-24T14:10:32.858Z',
},
};

export const jiraAggregatedResponse = [
{
id: 'jira.open_issues',
status: 'success',
metadata: {
title: 'Jira open blocking tickets',
description:
'Highlights the number of issues that are currently open in Jira.',
type: 'number',
history: true,
},
result: {
values: [
{ count: 6, name: 'success' },
{ count: 3, name: 'warning' },
{ count: 1, name: 'error' },
],
total: 10,
timestamp: '2026-01-24T14:10:32.776Z',
},
export const jiraAggregatedResponse = {
id: 'jira.open_issues',
status: 'success',
metadata: {
title: 'Jira open blocking tickets',
description:
'Highlights the number of issues that are currently open in Jira.',
type: 'number',
history: true,
},
];
result: {
values: [
{ count: 6, name: 'success' },
{ count: 3, name: 'warning' },
{ count: 1, name: 'error' },
],
total: 10,
timestamp: '2026-01-24T14:10:32.776Z',
},
};

export const emptyJiraAggregatedResponse = {
id: 'jira.open_issues',
status: 'success',
metadata: {
title: 'Jira open blocking tickets',
description:
'Highlights the number of critical, blocking issues that are currently open in Jira.',
type: 'number',
history: true,
},
result: {
total: 0,
values: [
{ count: 0, name: 'success' },
{ count: 0, name: 'warning' },
{ count: 0, name: 'error' },
],
timestamp: '2026-01-24T14:10:32.858Z',
},
};

export const emptyGithubAggregatedResponse = {
id: 'github.open_prs',
status: 'success',
metadata: {
title: 'GitHub open PRs',
description:
'Current count of open Pull Requests for a given GitHub repository.',
type: 'number',
history: true,
},
result: {
total: 0,
values: [
{ count: 0, name: 'success' },
{ count: 0, name: 'warning' },
{ count: 0, name: 'error' },
],
timestamp: '2026-01-24T14:10:32.858Z',
},
};
16 changes: 8 additions & 8 deletions workspaces/scorecard/plugins/scorecard-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ Returns a list of available metrics. Supports filtering by metric IDs or datasou

#### Query Parameters

| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | -------------------------------------------------------------------------------------------- |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter by (e.g., `github.open_prs,github.open_issues`) |
| `datasource` | string | No | Filter metrics by datasource ID (e.g., `github`, `jira`, `sonar`) |
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ------------------------------------------------------------------------------------------ |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter by (e.g., `github.open_prs,jira.open_issues`) |
| `datasource` | string | No | Filter metrics by datasource ID (e.g., `github`, `jira`, `sonar`) |

#### Behavior

Expand All @@ -127,7 +127,7 @@ curl -X GET "{{url}}/api/scorecard/metrics" \
-H "Authorization: Bearer <token>"

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

# Get all metrics from a specific datasource
Expand All @@ -149,9 +149,9 @@ Returns the latest metric values for a specific catalog entity.

#### Query Parameters

| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | -------------------------------------------------------------------------------------------- |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter by (e.g., `github.open_prs,github.open_issues`) |
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ------------------------------------------------------------------------------------------ |
| `metricIds` | string | No | Comma-separated list of metric IDs to filter by (e.g., `github.open_prs,jira.open_issues`) |

#### Permissions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,21 @@ type BuildMockDatabaseMetricValuesParams = {
metricValues?: DbMetricValue[];
latestEntityMetric?: DbMetricValue[];
countOfExpiredMetrics?: number;
aggregatedMetrics?: DbAggregatedMetric[];
aggregatedMetric?: DbAggregatedMetric;
};

export const mockDatabaseMetricValues = {
createMetricValues: jest.fn(),
readLatestEntityMetricValues: jest.fn(),
cleanupExpiredMetrics: jest.fn(),
readAggregatedMetricsByEntityRefs: jest.fn(),
readAggregatedMetricByEntityRefs: jest.fn(),
} as unknown as jest.Mocked<DatabaseMetricValues>;

export const buildMockDatabaseMetricValues = ({
metricValues,
latestEntityMetric,
countOfExpiredMetrics,
aggregatedMetrics,
aggregatedMetric,
}: BuildMockDatabaseMetricValuesParams) => {
const createMetricValues = metricValues
? jest.fn().mockResolvedValue(metricValues)
Expand All @@ -49,14 +49,14 @@ export const buildMockDatabaseMetricValues = ({
? jest.fn().mockResolvedValue(countOfExpiredMetrics)
: mockDatabaseMetricValues.cleanupExpiredMetrics;

const readAggregatedMetricsByEntityRefs = aggregatedMetrics
? jest.fn().mockResolvedValue(aggregatedMetrics)
: mockDatabaseMetricValues.readAggregatedMetricsByEntityRefs;
const readAggregatedMetricByEntityRefs = aggregatedMetric
? jest.fn().mockResolvedValue(aggregatedMetric)
: mockDatabaseMetricValues.readAggregatedMetricByEntityRefs;

return {
createMetricValues,
readLatestEntityMetricValues,
cleanupExpiredMetrics,
readAggregatedMetricsByEntityRefs,
readAggregatedMetricByEntityRefs,
} as unknown as jest.Mocked<DatabaseMetricValues>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,23 @@ curl -X GET "{{url}}/api/scorecard/metrics/github.open_prs/catalog/aggregations"
#### 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
- **Empty Results Handling**: Returns an empty array `[]` when the user owns no entities, avoiding errors when filtering by a single metric
- **Empty Results Handling**: Returns an empty aggregation object (zero counts with a timestamp) when the user owns no entities

## Error Handling

### Missing User Entity Reference

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

- **Status Code**: `401 Unauthorized`
- **Error**: `AuthenticationError: User entity reference not found`

### User Entity Not Found in the Catalog

If the user entity doesn't exist in the catalog.

- **Status Code**: `404 Not Found`
- **Error**: `NotFoundError: User entity reference not found`
- **Error**: `NotFoundError: User entity not found in catalog`

### Permission Denied

Expand All @@ -91,16 +98,9 @@ If the user doesn't have access to the specified metric:
- **Status Code**: `403 Forbidden`
- **Error**: `NotAllowedError: To view the scorecard metrics, your administrator must grant you the required permission.`

### Invalid Query Parameters

If invalid query parameters are provided:

- **Status Code**: `400 Bad Request`
- **Error**: Validation error details

## Best Practices

1. **Handle Empty Results**: Always check for empty arrays when the user owns no entities
1. **Handle Empty Results**: Always handle empty aggregations (zero counts) when the user owns no entities

2. **Group Structure**: Be aware of the direct parent group limitation when designing your group hierarchy. You currently receive scorecard results only for entities you own and those of your immediate parent group. To include results from _all_ parent
groups, you can either implement custom logic, restructure your groups, or (if using RHDH), enable transitive parent groups ([see transitive parent group enablement documentation](https://docs.redhat.com/en/documentation/red_hat_developer_hub/1.5/html-single/authorization_in_red_hat_developer_hub/index#enabling-transitive-parent-groups)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ describe('DatabaseMetricValues', () => {
);
});

describe('readAggregatedMetricsByEntityRefs', () => {
describe('readAggregatedMetricByEntityRefs', () => {
it.each(databases.eachSupportedId())(
'should return aggregated metrics by status for multiple entities and metrics - %p',
'should return aggregated metrics by status for multiple entities - %p',
async databaseId => {
const { client, db } = await createDatabase(databaseId);

Expand Down Expand Up @@ -227,34 +227,21 @@ describe('DatabaseMetricValues', () => {
},
]);

const result = await db.readAggregatedMetricsByEntityRefs(
const result = await db.readAggregatedMetricByEntityRefs(
[
'component:default/test-service',
'component:default/another-service',
],
['github.metric1', 'github.metric2'],
'github.metric2',
);

expect(result).toHaveLength(2);

const metric1Result = result.find(
r => r.metric_id === 'github.metric1',
);
const metric2Result = result.find(
r => r.metric_id === 'github.metric2',
);

expect(metric1Result).toBeDefined();
expect(metric1Result?.total).toBe(2);
expect(metric1Result?.success).toBe(2);
expect(metric1Result?.warning).toBe(0);
expect(metric1Result?.error).toBe(0);

expect(metric2Result).toBeDefined();
expect(metric2Result?.total).toBe(2);
expect(metric2Result?.success).toBe(0);
expect(metric2Result?.warning).toBe(1);
expect(metric2Result?.error).toBe(1);
expect(result).toBeDefined();
expect(result?.metric_id).toBe('github.metric2');
expect(result?.total).toBe(2);
expect(result?.success).toBe(0);
expect(result?.warning).toBe(1);
expect(result?.error).toBe(1);
expect(result?.max_timestamp).toEqual(laterTime);
},
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,17 @@ export class DatabaseMetricValues {
/**
* Get aggregated metrics by status for multiple entities and metrics.
*/
async readAggregatedMetricsByEntityRefs(
async readAggregatedMetricByEntityRefs(
catalog_entity_refs: string[],
metric_ids: string[],
): Promise<DbAggregatedMetric[]> {
metric_id: string,
): Promise<DbAggregatedMetric | undefined> {
const latestIdsSubquery = this.dbClient(this.tableName)
.max('id')
.whereIn('metric_id', metric_ids)
.where('metric_id', metric_id)
.whereIn('catalog_entity_ref', catalog_entity_refs)
.groupBy('metric_id', 'catalog_entity_ref');

const results = await this.dbClient(this.tableName)
const [row] = await this.dbClient(this.tableName)
.select('metric_id')
.count('* as total')
.max('timestamp as max_timestamp')
Expand All @@ -101,7 +101,7 @@ export class DatabaseMetricValues {
// Normalize types for cross-database compatibility
// PostgreSQL returns COUNT/SUM as strings, SQLite returns numbers
// PostgreSQL returns MAX(timestamp) as Date, SQLite returns number (milliseconds)
return results.map(row => {
if (row) {
let maxTimestamp: Date;
if (row.max_timestamp instanceof Date) {
maxTimestamp = row.max_timestamp;
Expand All @@ -122,6 +122,8 @@ export class DatabaseMetricValues {
warning: Number(row.warning),
error: Number(row.error),
};
});
}

return undefined;
}
}
Loading