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
8 changes: 8 additions & 0 deletions workspaces/scorecard/.changeset/evil-turtles-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-dora': minor
'@red-hat-developer-hub/backstage-plugin-scorecard-backend-module-jira': minor
---

Persist DORA collector data in the database and sync incrementally from the last watermark, so metrics reuse stored deployments, incidents, and pull requests instead of refetching the full window every time.

**BREAKING**: The Jira `jira:incidents` collector contract now requires `updatedSince` (ISO datetime) in the input and `updatedAt` (ISO datetime) on each incident in the output. Custom incident collector implementations must provide these fields.
6 changes: 6 additions & 0 deletions workspaces/scorecard/app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ scorecard:
# except:
# - openssf.maintained

# plugins:
# # Optional DORA source-data retention and freshness controls
# dora:
# dataRetentionDays: 365
# staleAfterMs: 60000

metricProviders:
jira:
openIssues:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,21 @@ DORA providers follow Scorecard scheduling settings under their metric keys:
- `scorecard.metricProviders.dora.changeFailureRate.schedule`

See [providers.md](../scorecard-backend/docs/providers.md#metric-collection-scheduling) for schedule schema and defaults.

## Data retention and staleness

Configure DORA module data retention and collector staleness behavior under
`scorecard.plugins.dora`:

```yaml
scorecard:
plugins:
dora:
dataRetentionDays: 365
staleAfterMs: 60000
```

- `dataRetentionDays`: how long source rows are retained before cleanup. Must be at least `30` (the DORA metric computation window). Defaults to `365`.
- `staleAfterMs`: freshness threshold in milliseconds for deployments and incidents; if the last sync is within this window, those collectors are not refreshed.

The module schedules a daily background task, `scorecard-dora:cleanup-expired-data`, that deletes deployments and incidents older than `dataRetentionDays`. Pull requests are removed when their parent deployment is deleted.
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,31 @@ import {
} from '@red-hat-developer-hub/backstage-plugin-scorecard-common';

export interface Config {
/** Configuration for scorecard dora plugin */
/** Configuration for scorecard dora plugin. */
scorecard?: {
plugins?: {
/**
* Configuration for scorecard dora plugin.
*/
dora?: {
/**
* Number of days to retain scorecard DORA source data (deployments, incidents,
* pull requests) in the database. Older data is cleaned up by the
* `scorecard-dora:cleanup-expired-data` task.
* Must be greater than or equal to the DORA metric computation window (30 days).
* @default 365
*/
dataRetentionDays?: number;
/**
* Freshness threshold in milliseconds for DORA deployment and incident collector refresh.
* If last successful deployments or incidents sync for a collector is within this value,
* data refresh is skipped and existing database data is reused.
* Set to `0` to always refresh.
* @default 60000
*/
staleAfterMs?: number;
};
};
metricProviders?: {
dora?: {
deploymentFrequency?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ Required output:

- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>`

Only deployments with `result: 'success'` are included in the calculation.

Ordering requirement:

- `deployments` must be in ascending `createdAt` order (oldest to newest). Order is required because the metric processes adjacent deployment pairs chronologically.
Expand Down Expand Up @@ -128,10 +130,14 @@ Required input:

- `from: string` (ISO datetime)
- `to: string` (ISO datetime)
- `updatedSince: string` (ISO datetime)

Required output:

- `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>`
- `incidents: Array<{ id: string; createdAt: string; updatedAt: string; resolutionAt: string | null }>`

`createdAt` and `updatedAt` must be valid ISO datetimes.
`resolutionAt` must be `null` for unresolved incidents or a valid ISO datetime for resolved incidents.

Collector-specific extra input fields are allowed, but they do not replace required contract fields.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Required output:

- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>`

Only deployments with `result: 'success'` are included in the calculation.

## Collector configuration

### Use GitHub deployments collector (default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,13 @@ Required input:

- `from: string` (ISO datetime)
- `to: string` (ISO datetime)
- `updatedSince: string` (ISO datetime)

Required output:

- `incidents: Array<{ id: string; createdAt: string; resolutionAt: string | null }>`
- `incidents: Array<{ id: string; createdAt: string; updatedAt: string; resolutionAt: string | null }>`

`createdAt` must be a valid ISO datetime.
`createdAt` and `updatedAt` must be valid ISO datetimes.
`resolutionAt` must be `null` for unresolved incidents or a valid ISO datetime for resolved incidents.

Collector-specific extra input fields are allowed, but they do not replace required contract fields.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@

Median Lead Time for Changes measures how long changes typically take to move from code to production.

The metric computes lead time for changes from pull request first commit timestamp to production deployment timestamp, then returns the median.
Deployments are processed as chronological pairs (`previousDeployment` -> `currentDeployment`), and pull requests are resolved for the commit range between those two deployment SHAs.
The metric computes lead time for changes from pull request first commit timestamp to successful production deployment timestamp, then returns the median.
Deployments are processed as chronological pairs of successful production deployments (`previousDeployment` -> `currentDeployment`), and pull requests are resolved for the commit range between those two deployment SHAs.
For each pull request in that range, lead time is `currentDeployment.createdAt - pullRequest.firstCommitAt` in hours.
The result is: `median(leadTimeHours)`.

Expand Down Expand Up @@ -96,6 +96,8 @@ Required output:

- `deployments: Array<{ id: string; commitSha: string; environment?: string; createdAt: string; result: 'success' | 'failure' | '' }>`

Only deployments with `result: 'success'` are included in the calculation.

Ordering requirement:

- `deployments` must be in ascending `createdAt` order (oldest to newest). Order is required because the metric processes adjacent deployment pairs chronologically.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/

// To create new migration file use: "yarn knex migrate:make migrations",
// open generated new migration file and edit it to complete code.
//
// This `knexfile.js` exports multiple named environments, so you must specify
// which one to use with `--env` when running migrations.
//
// Examples:
// - sqlite3: "yarn knex --env sqlite3 migrate:latest"
// - pg: "yarn knex --env pg migrate:latest"
//
// To run new migration use: "yarn knex --env sqlite3 migrate:up some_file_name"
// To run latest migration use: "yarn knex --env sqlite3 migrate:latest"
// To rollback concrete migration use: "yarn knex --env sqlite3 migrate:down some_file_name"
// To rollback latest migration batch use: "yarn knex --env sqlite3 migrate:rollback"

module.exports = {
sqlite3: {
client: 'better-sqlite3',
connection: ':memory:',
useNullAsDefault: true,
migrations: {
directory: './migrations',
tableName: 'dora_knex_migrations',
},
},
pg: {
client: 'pg',
connection: {
host: process.env.POSTGRES_HOST,
port: Number.parseInt(process.env.POSTGRES_PORT, 10),
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
database: process.env.POSTGRES_DB,
},
migrations: {
directory: './migrations',
tableName: 'dora_knex_migrations',
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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.
*/

exports.up = async function up(knex) {
await knex.schema.createTable('dora_deployments', table => {
table.string('id').primary().notNullable();
table.string('catalog_entity_ref').notNullable();
table.string('collector_id').notNullable();
table.string('original_deployment_id').notNullable();
table.string('commit_sha').notNullable();
table.string('environment').nullable();
// Millisecond precision so same-second deployments stay distinct for CFR/lead time.
table.dateTime('created_at', { precision: 3 }).notNullable();

table.unique([
'catalog_entity_ref',
'collector_id',
'original_deployment_id',
]);
// Deployment reads: entity + collector + created_at window (ordered by created_at)
table.index(
['catalog_entity_ref', 'collector_id', 'created_at'],
'dora_deployments_entity_collector_created_at_idx',
);
});

await knex.schema.createTable('dora_incidents', table => {
table.string('id').primary().notNullable();
table.string('catalog_entity_ref').notNullable();
table.string('collector_id').notNullable();
table.string('original_incident_id').notNullable();
table.dateTime('created_at', { precision: 3 }).notNullable();
table.dateTime('updated_at', { precision: 3 }).notNullable();
table.dateTime('resolution_at', { precision: 3 }).nullable();

table.unique([
'catalog_entity_ref',
'collector_id',
'original_incident_id',
]);
// Incident reads: entity + collector + created_at window (ordered by created_at)
table.index(
['catalog_entity_ref', 'collector_id', 'created_at'],
'dora_incidents_entity_collector_created_at_idx',
);
});

await knex.schema.createTable('dora_pull_requests', table => {
table.string('id').primary().notNullable();
table.string('catalog_entity_ref').notNullable();
table.string('collector_id').notNullable();
table.string('original_pr_id').notNullable();
table.dateTime('first_commit_at', { precision: 3 }).notNullable();
table
.string('deployment_id')
.references('id')
.inTable('dora_deployments')
.onDelete('CASCADE')
.notNullable();

table.unique([
'catalog_entity_ref',
'collector_id',
'original_pr_id',
'deployment_id',
]);
// Lead-time reads: PRs for one entity/collector/deployment
table.index(
['catalog_entity_ref', 'collector_id', 'deployment_id'],
'dora_pull_requests_entity_collector_deployment_idx',
);
});

await knex.schema.createTable('dora_last_sync', table => {
table.string('catalog_entity_ref').notNullable();
table.string('collector_id').notNullable();
table.dateTime('last_synced_at', { precision: 3 }).notNullable();

table.primary(['catalog_entity_ref', 'collector_id']);
});
};

exports.down = async function down(knex) {
await knex.schema.dropTable('dora_last_sync');
await knex.schema.dropTable('dora_pull_requests');
await knex.schema.dropTable('dora_incidents');
await knex.schema.dropTable('dora_deployments');
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@backstage/types": "^1.2.2",
"@red-hat-developer-hub/backstage-plugin-scorecard-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-scorecard-node": "workspace:^",
"knex": "^3.1.0",
"zod": "^3.22.4"
},
"devDependencies": {
Expand All @@ -54,7 +55,8 @@
},
"files": [
"config.d.ts",
"dist"
"dist",
"migrations"
],
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ export const DORA_DEFAULT_DEPLOYMENT_PULL_REQUESTS_COLLECTOR_ID =
export const DORA_DEFAULT_INCIDENTS_COLLECTOR_ID = 'jira:incidents';
export const DORA_TIME_WINDOW_DAYS = 30;
export const DORA_DEFAULT_PRODUCTION_ENVIRONMENTS = ['production'];
export const DORA_DEFAULT_DATA_RETENTION_DAYS = 365;
export const DORA_DEFAULT_STALE_AFTER_MS = 60_000;
export const DORA_CLEANUP_EXPIRED_DATA_TASK_ID =
'scorecard-dora:cleanup-expired-data' as const;
Loading
Loading